Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
|
||||
// 生成 telesrv 外观 seed(Default_appearance_seed.json + Default_wallpapers/{documents,thumbs/m}/*.dat)。
|
||||
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
|
||||
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
|
||||
//
|
||||
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
|
||||
|
|
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
}
|
||||
|
||||
api := client.API()
|
||||
docsDir := filepath.Join(outDir, "Default_wallpapers", "documents")
|
||||
thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m")
|
||||
docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
|
||||
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
return appearance.Document{}, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
out.Path = "Default_wallpapers/documents/" + name
|
||||
out.Path = "default_wallpapers/documents/" + name
|
||||
out.SHA256 = hex.EncodeToString(sum[:])
|
||||
// "m" 缩略图
|
||||
for _, t := range doc.Thumbs {
|
||||
|
|
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
tsum := sha256.Sum256(tdata)
|
||||
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
|
||||
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
|
||||
Path: "Default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
|
||||
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
|
@ -373,7 +373,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors))
|
||||
|
||||
catalog := appearance.Catalog{
|
||||
Source: "official telegram (appearancefetch)",
|
||||
Source: "upstream appearance snapshot",
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Notes: appearance.Notes{
|
||||
Server: "official",
|
||||
|
|
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonPath := filepath.Join(outDir, "Default_appearance_seed.json")
|
||||
jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
|
||||
if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
78
cmd/bots/aiogramecho/README.md
Normal file
78
cmd/bots/aiogramecho/README.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# aiogram 3 echo demo
|
||||
|
||||
该示例使用标准 aiogram 3 API,仅把 API server 指向 telesrv。aiogram 的
|
||||
`TelegramAPIServer.from_base()` 会自动拼出 telesrv 已支持的
|
||||
`/bot<TOKEN>/<method>` 与 `/file/bot<TOKEN>/<path>`。
|
||||
|
||||
```powershell
|
||||
python -m pip install -r .\cmd\bots\aiogramecho\requirements.txt
|
||||
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
|
||||
python .\cmd\bots\aiogramecho\echo.py --drop-pending
|
||||
```
|
||||
|
||||
## Ephemeral echo(Bot API 10.2)
|
||||
|
||||
先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,再在 TDesktop Layer 228
|
||||
的群组中发送:
|
||||
|
||||
```text
|
||||
/private@你的Bot用户名
|
||||
```
|
||||
|
||||
aiogram 3.30.0 原生解析 `ephemeral_message_id`。示例在 15 秒 action 窗口内携带
|
||||
`receiver_user_id` 和 `ReplyParameters(ephemeral_message_id=...)` 回复
|
||||
`ephemeral echo: ...`;失败不会降级成普通消息。Alice 应看到两条带
|
||||
可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示
|
||||
“Only visible to you”;Bob 不应看到其中任何一条。
|
||||
|
||||
发送三种语义色的 reply keyboard 与 inline callback 按钮:
|
||||
|
||||
```powershell
|
||||
python .\cmd\bots\aiogramecho\echo.py `
|
||||
--buttons-chat-id 1780243200 `
|
||||
--drop-pending
|
||||
```
|
||||
|
||||
可选的 `--button-icon-id <custom_emoji_document_id>` 同时验证按钮自定义 emoji
|
||||
图标。Telegram 官方会按 bot owner Premium / Fragment 权限限制图标使用;颜色只接受
|
||||
`primary`(蓝)、`success`(绿)、`danger`(红),不接受任意 RGB。
|
||||
|
||||
只主动发消息、不启动轮询:
|
||||
|
||||
```powershell
|
||||
python .\cmd\bots\aiogramecho\echo.py `
|
||||
--send-only `
|
||||
--send-chat-id 1780243200 `
|
||||
--send-text "hello from aiogram"
|
||||
```
|
||||
|
||||
默认 API server 是 `http://127.0.0.1:8081`,可用 `--base-url` 或
|
||||
`TELESRV_BOT_API_SERVER` 覆盖。不要在这里追加 `/bot`;这与 ptbecho 的
|
||||
`--base-url http://127.0.0.1:8081/bot` 参数格式不同。
|
||||
|
||||
轮询模式会回答本示例的 `aiogram-*` callback,也会对同一测试 bot 先前由
|
||||
其它 demo 创建的 inline callback 给出兜底确认,避免 TDesktop 按钮一直转圈。
|
||||
|
||||
## Webhook 模式
|
||||
|
||||
telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx
|
||||
后推进 `update_id`。aiogram 可监听本机 HTTP,由 Caddy/Nginx/Tunnel 提供公网 HTTPS:
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook"
|
||||
$env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret"
|
||||
python .\cmd\bots\aiogramecho\echo.py `
|
||||
--mode webhook `
|
||||
--listen-host 127.0.0.1 `
|
||||
--listen-port 8082 `
|
||||
--webhook-path /webhook `
|
||||
--drop-pending
|
||||
```
|
||||
|
||||
公网 URL 必须是 HTTPS,端口限 Telegram 标准的 443/80/88/8443;本机监听地址
|
||||
可以是 HTTP,因为 TLS 通常在反向代理终止。`secret_token` 会由 telesrv 放入
|
||||
`X-Telegram-Bot-Api-Secret-Token`,aiogram 会自动校验。若希望进程退出时删除配置,
|
||||
再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。
|
||||
|
||||
同一个 token 的 polling 与 webhook 互斥;切回轮询时直接以默认模式启动,示例会先
|
||||
调用 `deleteWebhook`,再开始 `getUpdates`。
|
||||
314
cmd/bots/aiogramecho/echo.py
Normal file
314
cmd/bots/aiogramecho/echo.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
#!/usr/bin/env python3
|
||||
"""aiogram 3 echo/button demo for the telesrv Bot API endpoint."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
from aiogram.enums import ButtonStyle
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
KeyboardButton,
|
||||
Message,
|
||||
ReplyParameters,
|
||||
ReplyKeyboardMarkup,
|
||||
)
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
LOG = logging.getLogger("aiogramecho")
|
||||
|
||||
|
||||
def env_int(name: str) -> int | None:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="aiogram 3 echo bot against telesrv")
|
||||
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"))
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"),
|
||||
help="API server origin; aiogram adds /bot<TOKEN> and /file/bot<TOKEN>",
|
||||
)
|
||||
parser.add_argument("--prefix", default="aiogram echo: ")
|
||||
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
|
||||
parser.add_argument("--drop-pending", action="store_true")
|
||||
parser.add_argument("--mode", choices=("polling", "webhook"), default=os.getenv("TELESRV_BOT_MODE", "polling"))
|
||||
parser.add_argument(
|
||||
"--webhook-url",
|
||||
default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""),
|
||||
help="Public HTTPS URL including the webhook path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--webhook-path",
|
||||
default=os.getenv("TELESRV_BOT_WEBHOOK_PATH", "/webhook"),
|
||||
help="Local aiohttp route, normally the path part of --webhook-url",
|
||||
)
|
||||
parser.add_argument("--webhook-secret", default=os.getenv("TELESRV_BOT_WEBHOOK_SECRET", "telesrv-aiogram-demo"))
|
||||
parser.add_argument("--listen-host", default=os.getenv("TELESRV_BOT_LISTEN_HOST", "127.0.0.1"))
|
||||
parser.add_argument("--listen-port", type=int, default=env_int("TELESRV_BOT_LISTEN_PORT") or 8082)
|
||||
parser.add_argument("--delete-webhook-on-exit", action="store_true")
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--send-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_CHAT_ID"))
|
||||
parser.add_argument("--send-text", default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", ""))
|
||||
parser.add_argument("--buttons-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"))
|
||||
parser.add_argument(
|
||||
"--button-icon-id",
|
||||
default=os.getenv("TELESRV_BOT_DEMO_BUTTON_ICON_ID"),
|
||||
help="Optional custom emoji document id used as the button icon",
|
||||
)
|
||||
parser.add_argument("--send-only", action="store_true")
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
args = parser.parse_args()
|
||||
if not args.token:
|
||||
parser.error("missing --token or TELESRV_BOT_TOKEN")
|
||||
if args.timeout < 0 or args.timeout > 50:
|
||||
parser.error("--timeout must be between 0 and 50")
|
||||
if args.send_text and args.send_chat_id is None:
|
||||
parser.error("--send-chat-id is required with --send-text")
|
||||
if args.send_only and not args.send_text and args.buttons_chat_id is None:
|
||||
parser.error("--send-only requires --send-text or --buttons-chat-id")
|
||||
if args.button_icon_id:
|
||||
try:
|
||||
if int(args.button_icon_id) <= 0:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise SystemExit("--button-icon-id must be a positive integer") from exc
|
||||
if args.mode == "webhook" and not args.webhook_url:
|
||||
parser.error("--webhook-url or TELESRV_BOT_WEBHOOK_URL is required in webhook mode")
|
||||
if not args.webhook_path.startswith("/") or "?" in args.webhook_path or "#" in args.webhook_path:
|
||||
parser.error("--webhook-path must be an absolute path without query or fragment")
|
||||
if args.listen_port < 1 or args.listen_port > 65535:
|
||||
parser.error("--listen-port must be between 1 and 65535")
|
||||
return args
|
||||
|
||||
|
||||
def reply_keyboard(icon_id: str | None) -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="Primary", style=ButtonStyle.PRIMARY, icon_custom_emoji_id=icon_id),
|
||||
KeyboardButton(text="Success", style=ButtonStyle.SUCCESS, icon_custom_emoji_id=icon_id),
|
||||
KeyboardButton(text="Danger", style=ButtonStyle.DANGER, icon_custom_emoji_id=icon_id),
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True,
|
||||
input_field_placeholder="Tap a colored reply button",
|
||||
)
|
||||
|
||||
|
||||
def inline_keyboard(icon_id: str | None) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="Primary",
|
||||
callback_data="aiogram-primary",
|
||||
style=ButtonStyle.PRIMARY,
|
||||
icon_custom_emoji_id=icon_id,
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="Success",
|
||||
callback_data="aiogram-success",
|
||||
style=ButtonStyle.SUCCESS,
|
||||
icon_custom_emoji_id=icon_id,
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="Danger",
|
||||
callback_data="aiogram-danger",
|
||||
style=ButtonStyle.DANGER,
|
||||
icon_custom_emoji_id=icon_id,
|
||||
),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_button_messages(bot: Bot, chat_id: int, icon_id: str | None) -> None:
|
||||
reply = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="TELESRV_AIOGRAM_REPLY_STYLES_20260719",
|
||||
reply_markup=reply_keyboard(icon_id),
|
||||
)
|
||||
inline = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="TELESRV_AIOGRAM_INLINE_STYLES_20260719",
|
||||
reply_markup=inline_keyboard(icon_id),
|
||||
)
|
||||
LOG.info(
|
||||
"sent styled buttons chat_id=%s reply_message_id=%s inline_message_id=%s",
|
||||
chat_id,
|
||||
reply.message_id,
|
||||
inline.message_id,
|
||||
)
|
||||
|
||||
|
||||
def ephemeral_message_id(message: object) -> int | None:
|
||||
"""Read the native aiogram field, retaining an extra-field fallback."""
|
||||
raw = getattr(message, "ephemeral_message_id", None)
|
||||
if raw is None:
|
||||
raw = (getattr(message, "model_extra", None) or {}).get("ephemeral_message_id")
|
||||
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
async def send_echo(message: Message, prefix: str, ephemeral_prefix: str):
|
||||
text = message.text or message.caption or ""
|
||||
if not text:
|
||||
return None
|
||||
transient_id = ephemeral_message_id(message)
|
||||
if transient_id is None:
|
||||
return await message.answer(prefix + text)
|
||||
|
||||
if message.from_user is None:
|
||||
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
|
||||
return None
|
||||
return await message.bot.send_message(
|
||||
chat_id=message.chat.id,
|
||||
text=ephemeral_prefix + text,
|
||||
receiver_user_id=message.from_user.id,
|
||||
reply_parameters=ReplyParameters(ephemeral_message_id=transient_id),
|
||||
)
|
||||
|
||||
|
||||
def build_dispatcher(args: argparse.Namespace) -> Dispatcher:
|
||||
router = Router(name="telesrv-aiogramecho")
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message) -> None:
|
||||
await message.answer("send /ping <text>, /buttons, or any private text")
|
||||
|
||||
@router.message(Command("buttons"))
|
||||
async def buttons(message: Message) -> None:
|
||||
await send_button_messages(message.bot, message.chat.id, args.button_icon_id)
|
||||
|
||||
@router.message(Command("ping"))
|
||||
async def ping(message: Message) -> None:
|
||||
await send_echo(message, args.prefix, args.ephemeral_prefix)
|
||||
|
||||
@router.message(Command("private"))
|
||||
async def private(message: Message) -> None:
|
||||
sent = await send_echo(message, args.prefix, args.ephemeral_prefix)
|
||||
LOG.info(
|
||||
"echoed ephemeral chat_id=%s ephemeral_message_id=%s sent_ephemeral_message_id=%s",
|
||||
message.chat.id,
|
||||
ephemeral_message_id(message),
|
||||
ephemeral_message_id(sent) if sent is not None else None,
|
||||
)
|
||||
|
||||
@router.callback_query(F.data.startswith("aiogram-"))
|
||||
async def callback(query: CallbackQuery) -> None:
|
||||
await query.answer(f"telesrv {query.data} callback OK")
|
||||
LOG.info("answered callback query_id=%s data=%r", query.id, query.data)
|
||||
|
||||
@router.callback_query()
|
||||
async def fallback_callback(query: CallbackQuery) -> None:
|
||||
"""Keep the echo demo responsive for buttons created by another demo."""
|
||||
await query.answer("telesrv callback OK")
|
||||
LOG.info("answered fallback callback query_id=%s data=%r", query.id, query.data)
|
||||
|
||||
@router.message(F.text)
|
||||
async def echo(message: Message) -> None:
|
||||
await send_echo(message, args.prefix, args.ephemeral_prefix)
|
||||
LOG.info("echoed chat_id=%s message_id=%s", message.chat.id, message.message_id)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(router)
|
||||
return dispatcher
|
||||
|
||||
|
||||
def build_bot(args: argparse.Namespace) -> Bot:
|
||||
session = AiohttpSession(api=TelegramAPIServer.from_base(args.base_url.rstrip("/")))
|
||||
return Bot(token=args.token, session=session)
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> None:
|
||||
bot = build_bot(args)
|
||||
runner: web.AppRunner | None = None
|
||||
try:
|
||||
me = await bot.get_me()
|
||||
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
|
||||
if args.send_chat_id is not None and args.send_text:
|
||||
sent = await bot.send_message(chat_id=args.send_chat_id, text=args.send_text)
|
||||
LOG.info("sent proactive chat_id=%s message_id=%s", args.send_chat_id, sent.message_id)
|
||||
if args.buttons_chat_id is not None:
|
||||
await send_button_messages(bot, args.buttons_chat_id, args.button_icon_id)
|
||||
if args.send_only:
|
||||
return
|
||||
dispatcher = build_dispatcher(args)
|
||||
allowed_updates = ["message", "edited_message", "callback_query"]
|
||||
if args.mode == "polling":
|
||||
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
|
||||
await dispatcher.start_polling(
|
||||
bot,
|
||||
allowed_updates=allowed_updates,
|
||||
polling_timeout=args.timeout,
|
||||
close_bot_session=False,
|
||||
)
|
||||
return
|
||||
|
||||
application = web.Application()
|
||||
SimpleRequestHandler(
|
||||
dispatcher=dispatcher,
|
||||
bot=bot,
|
||||
secret_token=args.webhook_secret,
|
||||
).register(application, path=args.webhook_path)
|
||||
setup_application(application, dispatcher, bot=bot)
|
||||
await bot.set_webhook(
|
||||
url=args.webhook_url,
|
||||
secret_token=args.webhook_secret,
|
||||
allowed_updates=allowed_updates,
|
||||
drop_pending_updates=args.drop_pending,
|
||||
)
|
||||
runner = web.AppRunner(application)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host=args.listen_host, port=args.listen_port)
|
||||
await site.start()
|
||||
LOG.info(
|
||||
"webhook listening on http://%s:%s%s, public_url=%s",
|
||||
args.listen_host,
|
||||
args.listen_port,
|
||||
args.webhook_path,
|
||||
args.webhook_url,
|
||||
)
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
if args.mode == "webhook" and args.delete_webhook_on_exit:
|
||||
try:
|
||||
await bot.delete_webhook()
|
||||
except Exception: # pragma: no cover - best-effort shutdown logging
|
||||
LOG.exception("failed to delete webhook during shutdown")
|
||||
if runner is not None:
|
||||
await runner.cleanup()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
asyncio.run(run(args))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
cmd/bots/aiogramecho/requirements.txt
Normal file
1
cmd/bots/aiogramecho/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
aiogram==3.30.0
|
||||
63
cmd/bots/aiogramecho/test_echo.py
Normal file
63
cmd/bots/aiogramecho/test_echo.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import echo
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def send_message(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return SimpleNamespace(message_id=0, ephemeral_message_id=88)
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, *, text="hello", ephemeral_id=None):
|
||||
self.text = text
|
||||
self.caption = None
|
||||
self.chat = SimpleNamespace(id=-1000000000002)
|
||||
self.from_user = SimpleNamespace(id=1780243200)
|
||||
self.ephemeral_message_id = ephemeral_id
|
||||
self.model_extra = {}
|
||||
self.bot = FakeBot()
|
||||
self.answers = []
|
||||
|
||||
async def answer(self, text):
|
||||
self.answers.append(text)
|
||||
return SimpleNamespace(message_id=1, ephemeral_message_id=None)
|
||||
|
||||
|
||||
class EchoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_ephemeral_echo_uses_receiver_and_transient_reply(self):
|
||||
message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77)
|
||||
|
||||
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
|
||||
|
||||
self.assertEqual(message.answers, [])
|
||||
self.assertEqual(len(message.bot.calls), 1)
|
||||
call = message.bot.calls[0]
|
||||
self.assertEqual(call["chat_id"], -1000000000002)
|
||||
self.assertEqual(call["text"], "ephemeral echo: /private@TetrisBot")
|
||||
self.assertEqual(call["receiver_user_id"], 1780243200)
|
||||
self.assertEqual(call["reply_parameters"].ephemeral_message_id, 77)
|
||||
|
||||
async def test_normal_echo_stays_on_standard_answer_path(self):
|
||||
message = FakeMessage()
|
||||
|
||||
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
|
||||
|
||||
self.assertEqual(message.answers, ["aiogram echo: hello"])
|
||||
self.assertEqual(message.bot.calls, [])
|
||||
|
||||
def test_extra_field_fallback_and_invalid_values(self):
|
||||
message = FakeMessage()
|
||||
message.model_extra = {"ephemeral_message_id": 66}
|
||||
self.assertEqual(echo.ephemeral_message_id(message), 66)
|
||||
message.model_extra = {"ephemeral_message_id": True}
|
||||
self.assertIsNone(echo.ephemeral_message_id(message))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
50
cmd/bots/bedolagaformat/README.md
Normal file
50
cmd/bots/bedolagaformat/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Bedolaga formatted-text demo
|
||||
|
||||
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
|
||||
|
||||
```python
|
||||
Bot(
|
||||
...,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
```
|
||||
|
||||
因此 `/start` 的 `message.answer()` 不显式传 `parse_mode`,仍会由 aiogram 自动向
|
||||
telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legacy Markdown、
|
||||
MarkdownV2,用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
|
||||
TDesktop` 链路。
|
||||
|
||||
## 安装
|
||||
|
||||
建议使用虚拟环境,token 只通过环境变量传入:
|
||||
|
||||
```powershell
|
||||
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
|
||||
-r .\cmd\bots\bedolagaformat\requirements.txt
|
||||
|
||||
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
|
||||
$env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\demo.py --drop-pending
|
||||
```
|
||||
|
||||
随后在 TDesktop 中向 bot 发送:
|
||||
|
||||
```text
|
||||
/start
|
||||
/formatdemo
|
||||
```
|
||||
|
||||
也可以不启动 polling,直接向指定私聊发送三条格式测试消息:
|
||||
|
||||
```powershell
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\demo.py `
|
||||
--send-only `
|
||||
--send-chat-id 1780243200 `
|
||||
--marker BEDOLAGA-LOCAL-VERIFY
|
||||
```
|
||||
|
||||
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会
|
||||
把 token 写入文件。
|
||||
221
cmd/bots/bedolagaformat/demo.py
Normal file
221
cmd/bots/bedolagaformat/demo.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bedolaga-style aiogram formatted-text demo for the telesrv Bot API.
|
||||
|
||||
The bot factory intentionally mirrors remnawave-bedolaga-telegram-bot:
|
||||
DefaultBotProperties(parse_mode=ParseMode.HTML) is installed globally, while
|
||||
individual sends may override it with legacy Markdown or MarkdownV2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from aiogram import Bot, Dispatcher, Router
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
LOG = logging.getLogger("bedolagaformat")
|
||||
MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||
MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormatSample:
|
||||
name: str
|
||||
text: str
|
||||
parse_mode: ParseMode | None
|
||||
|
||||
|
||||
def default_marker() -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.strftime("BEDOLAGA%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def escape_markdown_v2_text(value: str) -> str:
|
||||
return MARKDOWN_V2_RESERVED_RE.sub(r"\\\1", value)
|
||||
|
||||
|
||||
def format_samples(marker: str) -> tuple[FormatSample, ...]:
|
||||
"""Return deterministic messages whose labels are safe in every grammar."""
|
||||
markdown_v2_marker = escape_markdown_v2_text(marker)
|
||||
return (
|
||||
FormatSample(
|
||||
name="default_html",
|
||||
text=(
|
||||
f"<b>{marker} Default HTML</b> "
|
||||
"<i>italic 😀</i> <u>underline</u> "
|
||||
"<tg-spoiler>spoiler</tg-spoiler> "
|
||||
'<a href="https://example.com/bedolaga">link</a>'
|
||||
),
|
||||
# Deliberately omitted from send_message: the Bedolaga factory default
|
||||
# must inject HTML just as it does for message.answer() in start.py.
|
||||
parse_mode=None,
|
||||
),
|
||||
FormatSample(
|
||||
name="markdown",
|
||||
text=(
|
||||
f"*{marker} Markdown* _italic 😀_ "
|
||||
"[link](https://example.com/bedolaga) `code`"
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
),
|
||||
FormatSample(
|
||||
name="markdown_v2",
|
||||
text=(
|
||||
f"*{markdown_v2_marker} MarkdownV2* _italic 😀_ __underline__ "
|
||||
"~strike~ ||spoiler|| "
|
||||
"[link](https://example.com/bedolaga) `code`"
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bedolaga-style aiogram HTML/Markdown demo against telesrv"
|
||||
)
|
||||
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"))
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"),
|
||||
help="API server origin; do not append /bot<TOKEN>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--send-chat-id",
|
||||
type=int,
|
||||
default=int(os.environ["TELESRV_BOT_DEMO_CHAT_ID"])
|
||||
if os.getenv("TELESRV_BOT_DEMO_CHAT_ID")
|
||||
else None,
|
||||
help="Send the complete suite proactively before polling",
|
||||
)
|
||||
parser.add_argument("--send-only", action="store_true")
|
||||
parser.add_argument("--drop-pending", action="store_true")
|
||||
parser.add_argument("--polling-timeout", type=int, default=10)
|
||||
parser.add_argument("--marker", default=default_marker())
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
args = parser.parse_args()
|
||||
if not args.token:
|
||||
parser.error("missing --token or TELESRV_BOT_TOKEN")
|
||||
if args.send_only and args.send_chat_id is None:
|
||||
parser.error("--send-only requires --send-chat-id")
|
||||
if not MARKER_RE.fullmatch(args.marker):
|
||||
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
|
||||
if not 0 <= args.polling_timeout <= 50:
|
||||
parser.error("--polling-timeout must be between 0 and 50")
|
||||
return args
|
||||
|
||||
|
||||
def create_bot(token: str, base_url: str) -> Bot:
|
||||
"""Mirror Bedolaga's create_bot() with a custom Telegram API server."""
|
||||
session = AiohttpSession(
|
||||
api=TelegramAPIServer.from_base(base_url.rstrip("/"))
|
||||
)
|
||||
return Bot(
|
||||
token=token,
|
||||
session=session,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
|
||||
async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
|
||||
message_ids: list[int] = []
|
||||
for sample in format_samples(marker):
|
||||
if sample.parse_mode is None:
|
||||
sent = await bot.send_message(chat_id=chat_id, text=sample.text)
|
||||
else:
|
||||
sent = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=sample.text,
|
||||
parse_mode=sample.parse_mode,
|
||||
)
|
||||
message_ids.append(sent.message_id)
|
||||
LOG.info(
|
||||
"sent sample=%s chat_id=%s message_id=%s parse_mode=%s",
|
||||
sample.name,
|
||||
chat_id,
|
||||
sent.message_id,
|
||||
sample.parse_mode.value if sample.parse_mode is not None else "default-html",
|
||||
)
|
||||
return message_ids
|
||||
|
||||
|
||||
def build_dispatcher(marker: str) -> Dispatcher:
|
||||
router = Router(name="telesrv-bedolaga-format")
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message) -> None:
|
||||
# No parse_mode argument: this is the exact failure shape from Bedolaga's
|
||||
# start handler when the Bot factory installs default HTML globally.
|
||||
await message.answer(
|
||||
f"<b>{marker} Start OK</b> <i>default HTML inherited</i> 😀"
|
||||
)
|
||||
LOG.info("handled /start chat_id=%s incoming_message_id=%s", message.chat.id, message.message_id)
|
||||
|
||||
@router.message(Command("formatdemo"))
|
||||
async def format_demo(message: Message) -> None:
|
||||
ids = await send_format_suite(message.bot, message.chat.id, marker)
|
||||
LOG.info(
|
||||
"handled /formatdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
ids,
|
||||
)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(router)
|
||||
return dispatcher
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> None:
|
||||
bot = create_bot(args.token, args.base_url)
|
||||
try:
|
||||
me = await bot.get_me()
|
||||
LOG.info(
|
||||
"authenticated bot_id=%s username=@%s bot_api=%s marker=%s",
|
||||
me.id,
|
||||
me.username or "",
|
||||
args.base_url,
|
||||
args.marker,
|
||||
)
|
||||
if args.send_chat_id is not None:
|
||||
await send_format_suite(bot, args.send_chat_id, args.marker)
|
||||
if args.send_only:
|
||||
return
|
||||
|
||||
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
|
||||
dispatcher = build_dispatcher(args.marker)
|
||||
LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id)
|
||||
await dispatcher.start_polling(
|
||||
bot,
|
||||
allowed_updates=["message"],
|
||||
polling_timeout=args.polling_timeout,
|
||||
close_bot_session=False,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
asyncio.run(run(args))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
aiogram==3.30.0
|
||||
52
cmd/bots/bedolagaformat/test_demo.py
Normal file
52
cmd/bots/bedolagaformat/test_demo.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("demo.py")
|
||||
SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
demo = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = demo
|
||||
SPEC.loader.exec_module(demo)
|
||||
|
||||
|
||||
class SentMessage:
|
||||
def __init__(self, message_id: int) -> None:
|
||||
self.message_id = message_id
|
||||
|
||||
|
||||
class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_format_samples_cover_default_and_explicit_modes(self) -> None:
|
||||
samples = demo.format_samples("BEDOLAGA123")
|
||||
self.assertEqual(
|
||||
[sample.parse_mode for sample in samples],
|
||||
[None, ParseMode.MARKDOWN, ParseMode.MARKDOWN_V2],
|
||||
)
|
||||
self.assertIn("<b>BEDOLAGA123 Default HTML</b>", samples[0].text)
|
||||
self.assertIn("*BEDOLAGA123 Markdown*", samples[1].text)
|
||||
self.assertIn("*BEDOLAGA123 MarkdownV2*", samples[2].text)
|
||||
|
||||
def test_markdown_v2_escapes_reserved_marker_characters(self) -> None:
|
||||
samples = demo.format_samples("BEDOLAGA-FULL-20260720")
|
||||
self.assertIn(r"BEDOLAGA\-FULL\-20260720", samples[2].text)
|
||||
|
||||
async def test_send_suite_omits_parse_mode_only_for_default_html(self) -> None:
|
||||
bot = AsyncMock()
|
||||
bot.send_message.side_effect = [SentMessage(11), SentMessage(12), SentMessage(13)]
|
||||
|
||||
message_ids = await demo.send_format_suite(bot, 1780243200, "BEDOLAGA123")
|
||||
|
||||
self.assertEqual(message_ids, [11, 12, 13])
|
||||
calls = bot.send_message.await_args_list
|
||||
self.assertNotIn("parse_mode", calls[0].kwargs)
|
||||
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
|
||||
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -17,6 +17,21 @@ In a group with BotFather privacy enabled, send a command such as:
|
|||
/ping hello from group
|
||||
```
|
||||
|
||||
## Ephemeral echo(Bot API 10.2)
|
||||
|
||||
先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,然后保持本示例轮询。
|
||||
在 TDesktop Layer 228 的群组里发送:
|
||||
|
||||
```text
|
||||
/private@你的Bot用户名
|
||||
```
|
||||
|
||||
本示例从 PTB 的 `Message.api_kwargs` 读取 `ephemeral_message_id`,并在 15 秒 action
|
||||
窗口内用 `receiver_user_id` 与 `reply_parameters.ephemeral_message_id` 回复
|
||||
`ephemeral echo: ...`。请求失败时不会降级成普通群消息。Alice 应看到两条带
|
||||
可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示
|
||||
“Only visible to you”;Bob 不应看到其中任何一条。
|
||||
|
||||
主动发送一条消息并退出:
|
||||
|
||||
```powershell
|
||||
|
|
@ -34,11 +49,24 @@ python .\cmd\bots\ptbecho\echo.py `
|
|||
--send-text "ptbecho is online"
|
||||
```
|
||||
|
||||
发送 reply keyboard 与 inline callback 两条验证消息并保持 polling:
|
||||
|
||||
```powershell
|
||||
python .\cmd\bots\ptbecho\echo.py `
|
||||
--buttons-chat-id 1780243200
|
||||
```
|
||||
|
||||
reply keyboard 与 inline keyboard 都会各显示蓝/绿/红三种语义色;点击 reply
|
||||
button 会按普通文本消息进入 echo 链,点击 inline button 会由 `callback_query` handler 调用
|
||||
`answerCallbackQuery` 并显示 `telesrv inline callback OK`。也可以在私聊中发送
|
||||
`/buttons` 生成同样的两条消息。
|
||||
|
||||
可选参数:
|
||||
|
||||
- `--send-count N`:连续主动发送 N 条。
|
||||
- `--send-interval SEC`:连续发送之间的间隔。
|
||||
- `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。
|
||||
- `--buttons-chat-id` / `TELESRV_BOT_DEMO_BUTTONS_CHAT_ID`:发送两类键盘验证消息并监听 callback。
|
||||
|
||||
本地超级群 chat id 使用 Bot API 形式 `-100<channel_id>`;例如 channel id 为
|
||||
`2` 时是 `-1000000000002`。
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ to the bot, mentions, or messages otherwise visible to bots. In a group, send:
|
|||
|
||||
/ping hello
|
||||
|
||||
For a command registered with ``is_ephemeral=true``, send:
|
||||
|
||||
/private@YourBotUsername
|
||||
|
||||
The incoming Bot API message has ``message_id=0`` and carries the transient
|
||||
identifier in ``api_kwargs`` until python-telegram-bot exposes the Bot API 10.2
|
||||
fields directly. The demo replies through the same ephemeral action window.
|
||||
|
||||
The same program can also send proactive messages:
|
||||
|
||||
python cmd/bots/ptbecho/echo.py \
|
||||
|
|
@ -32,11 +40,18 @@ import os
|
|||
import signal
|
||||
from typing import Iterable
|
||||
|
||||
from telegram import Bot, Update
|
||||
from telegram.constants import ChatAction
|
||||
from telegram import (
|
||||
Bot,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
KeyboardButton,
|
||||
ReplyKeyboardMarkup,
|
||||
Update,
|
||||
)
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
ApplicationBuilder,
|
||||
CallbackQueryHandler,
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
MessageHandler,
|
||||
|
|
@ -63,6 +78,7 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument("--base-url", default=os.getenv("TELESRV_BOT_API_BASE_URL", "http://127.0.0.1:8081/bot"))
|
||||
parser.add_argument("--base-file-url", default=os.getenv("TELESRV_BOT_API_BASE_FILE_URL", "http://127.0.0.1:8081/file/bot"))
|
||||
parser.add_argument("--prefix", default="echo: ")
|
||||
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
|
||||
parser.add_argument("--drop-pending", action="store_true", help="Drop pending updates before polling")
|
||||
parser.add_argument("--timeout", type=int, default=30, help="getUpdates long-poll timeout seconds")
|
||||
parser.add_argument(
|
||||
|
|
@ -78,6 +94,12 @@ def parse_args() -> argparse.Namespace:
|
|||
)
|
||||
parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send")
|
||||
parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends")
|
||||
parser.add_argument(
|
||||
"--buttons-chat-id",
|
||||
type=int,
|
||||
default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"),
|
||||
help="Send reply/inline keyboard validation messages to this chat on startup",
|
||||
)
|
||||
parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling")
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
args = parser.parse_args()
|
||||
|
|
@ -87,11 +109,10 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.error("--send-count must be >= 1")
|
||||
if args.send_interval < 0:
|
||||
parser.error("--send-interval must be >= 0")
|
||||
wants_send = args.send_only or bool(args.send_text)
|
||||
if wants_send and args.send_chat_id is None:
|
||||
if args.send_text and args.send_chat_id is None:
|
||||
parser.error("--send-chat-id is required when --send-text or --send-only is used")
|
||||
if args.send_only and not args.send_text:
|
||||
parser.error("--send-only requires --send-text")
|
||||
if args.send_only and not args.send_text and args.buttons_chat_id is None:
|
||||
parser.error("--send-only requires --send-text or --buttons-chat-id")
|
||||
return args
|
||||
|
||||
|
||||
|
|
@ -105,6 +126,38 @@ async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||
await echo(update, context)
|
||||
|
||||
|
||||
def ephemeral_message_id(message: object) -> int | None:
|
||||
"""Read a Bot API 10.2 field without depending on a PTB release cycle."""
|
||||
raw = getattr(message, "ephemeral_message_id", None)
|
||||
if raw is None:
|
||||
raw = (getattr(message, "api_kwargs", None) or {}).get("ephemeral_message_id")
|
||||
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
async def send_echo(message: object, bot: Bot, prefix: str, ephemeral_prefix: str):
|
||||
text = getattr(message, "text", None) or getattr(message, "caption", None) or ""
|
||||
if not text:
|
||||
return None
|
||||
transient_id = ephemeral_message_id(message)
|
||||
if transient_id is None:
|
||||
return await message.reply_text(prefix + text)
|
||||
|
||||
sender = getattr(message, "from_user", None)
|
||||
if sender is None:
|
||||
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
|
||||
return None
|
||||
return await bot.send_message(
|
||||
chat_id=message.chat_id,
|
||||
text=ephemeral_prefix + text,
|
||||
api_kwargs={
|
||||
"receiver_user_id": sender.id,
|
||||
"reply_parameters": {"ephemeral_message_id": transient_id},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_message is None or update.effective_chat is None:
|
||||
return
|
||||
|
|
@ -112,18 +165,44 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||
if not text:
|
||||
return
|
||||
prefix = context.application.bot_data.get("prefix", "echo: ")
|
||||
await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING)
|
||||
sent = await update.effective_message.reply_text(prefix + text)
|
||||
ephemeral_prefix = context.application.bot_data.get("ephemeral_prefix", "ephemeral echo: ")
|
||||
transient_id = ephemeral_message_id(update.effective_message)
|
||||
sent = await send_echo(update.effective_message, context.bot, prefix, ephemeral_prefix)
|
||||
if sent is None:
|
||||
return
|
||||
LOG.info(
|
||||
"echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r",
|
||||
"echoed update_id=%s chat_id=%s message_id=%s ephemeral_message_id=%s "
|
||||
"sent_message_id=%s sent_ephemeral_message_id=%s text=%r",
|
||||
update.update_id,
|
||||
update.effective_chat.id,
|
||||
update.effective_message.message_id,
|
||||
transient_id,
|
||||
sent.message_id,
|
||||
ephemeral_message_id(sent),
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
async def buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_chat is None:
|
||||
return
|
||||
await send_button_messages(context.bot, update.effective_chat.id)
|
||||
|
||||
|
||||
async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
query = update.callback_query
|
||||
if query is None:
|
||||
return
|
||||
await query.answer("telesrv inline callback OK")
|
||||
LOG.info(
|
||||
"answered callback query_id=%s chat_id=%s message_id=%s data=%r",
|
||||
query.id,
|
||||
query.message.chat_id if query.message else None,
|
||||
query.message.message_id if query.message else None,
|
||||
query.data,
|
||||
)
|
||||
|
||||
|
||||
async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
|
||||
for index in range(count):
|
||||
sent = await bot.send_message(chat_id=chat_id, text=text)
|
||||
|
|
@ -139,25 +218,63 @@ async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, in
|
|||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def send_button_messages(bot: Bot, chat_id: int) -> None:
|
||||
reply = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="TELESRV_REPLY_KEYBOARD_20260719",
|
||||
reply_markup=ReplyKeyboardMarkup(
|
||||
[[
|
||||
KeyboardButton("Primary", api_kwargs={"style": "primary"}),
|
||||
KeyboardButton("Success", api_kwargs={"style": "success"}),
|
||||
KeyboardButton("Danger", api_kwargs={"style": "danger"}),
|
||||
]],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True,
|
||||
input_field_placeholder="Tap the reply button",
|
||||
),
|
||||
)
|
||||
inline = await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="TELESRV_INLINE_CALLBACK_20260719",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
[[
|
||||
InlineKeyboardButton("Primary", callback_data="telesrv-primary", api_kwargs={"style": "primary"}),
|
||||
InlineKeyboardButton("Success", callback_data="telesrv-success", api_kwargs={"style": "success"}),
|
||||
InlineKeyboardButton("Danger", callback_data="telesrv-danger", api_kwargs={"style": "danger"}),
|
||||
]],
|
||||
),
|
||||
)
|
||||
LOG.info(
|
||||
"sent keyboard validation chat_id=%s reply_message_id=%s inline_message_id=%s",
|
||||
chat_id,
|
||||
reply.message_id,
|
||||
inline.message_id,
|
||||
)
|
||||
|
||||
|
||||
async def send_on_startup(app: Application) -> None:
|
||||
chat_id = app.bot_data.get("send_chat_id")
|
||||
text = app.bot_data.get("send_text")
|
||||
if chat_id is None or not text:
|
||||
return
|
||||
await send_active_messages(
|
||||
app.bot,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
count=int(app.bot_data.get("send_count", 1)),
|
||||
interval=float(app.bot_data.get("send_interval", 1.0)),
|
||||
)
|
||||
if chat_id is not None and text:
|
||||
await send_active_messages(
|
||||
app.bot,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
count=int(app.bot_data.get("send_count", 1)),
|
||||
interval=float(app.bot_data.get("send_interval", 1.0)),
|
||||
)
|
||||
buttons_chat_id = app.bot_data.get("buttons_chat_id")
|
||||
if buttons_chat_id is not None:
|
||||
await send_button_messages(app.bot, int(buttons_chat_id))
|
||||
|
||||
|
||||
async def post_init(app: Application) -> None:
|
||||
me = await app.bot.get_me()
|
||||
LOG.info("listening as @%s (%s), bot_api=%s", me.username or me.id, me.id, app.bot_data["base_url"])
|
||||
if app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text"):
|
||||
app.create_task(send_on_startup(app), name="ptbecho-proactive-send")
|
||||
if (app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text")) or app.bot_data.get(
|
||||
"buttons_chat_id"
|
||||
) is not None:
|
||||
await send_on_startup(app)
|
||||
|
||||
|
||||
def build_app(args: argparse.Namespace) -> Application:
|
||||
|
|
@ -170,13 +287,18 @@ def build_app(args: argparse.Namespace) -> Application:
|
|||
.build()
|
||||
)
|
||||
app.bot_data["prefix"] = args.prefix
|
||||
app.bot_data["ephemeral_prefix"] = args.ephemeral_prefix
|
||||
app.bot_data["base_url"] = args.base_url
|
||||
app.bot_data["send_chat_id"] = args.send_chat_id
|
||||
app.bot_data["send_text"] = args.send_text
|
||||
app.bot_data["send_count"] = args.send_count
|
||||
app.bot_data["send_interval"] = args.send_interval
|
||||
app.bot_data["buttons_chat_id"] = args.buttons_chat_id
|
||||
app.add_handler(CommandHandler("start", start))
|
||||
app.add_handler(CommandHandler("ping", ping))
|
||||
app.add_handler(CommandHandler("private", echo))
|
||||
app.add_handler(CommandHandler("buttons", buttons))
|
||||
app.add_handler(CallbackQueryHandler(callback))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
|
||||
return app
|
||||
|
||||
|
|
@ -185,18 +307,21 @@ async def run_send_only(args: argparse.Namespace) -> None:
|
|||
bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url)
|
||||
me = await bot.get_me()
|
||||
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
|
||||
await send_active_messages(
|
||||
bot,
|
||||
chat_id=args.send_chat_id,
|
||||
text=args.send_text,
|
||||
count=args.send_count,
|
||||
interval=args.send_interval,
|
||||
)
|
||||
if args.send_chat_id is not None and args.send_text:
|
||||
await send_active_messages(
|
||||
bot,
|
||||
chat_id=args.send_chat_id,
|
||||
text=args.send_text,
|
||||
count=args.send_count,
|
||||
interval=args.send_interval,
|
||||
)
|
||||
if args.buttons_chat_id is not None:
|
||||
await send_button_messages(bot, args.buttons_chat_id)
|
||||
|
||||
|
||||
def stop_signals() -> Iterable[int]:
|
||||
def stop_signals() -> Iterable[int] | None:
|
||||
if os.name == "nt":
|
||||
return (signal.SIGINT, signal.SIGTERM)
|
||||
return None
|
||||
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
||||
|
||||
|
||||
|
|
@ -214,7 +339,7 @@ def main() -> int:
|
|||
|
||||
app = build_app(args)
|
||||
app.run_polling(
|
||||
allowed_updates=["message", "edited_message"],
|
||||
allowed_updates=["message", "edited_message", "callback_query"],
|
||||
drop_pending_updates=args.drop_pending,
|
||||
poll_interval=0.0,
|
||||
timeout=args.timeout,
|
||||
|
|
|
|||
70
cmd/bots/ptbecho/test_echo.py
Normal file
70
cmd/bots/ptbecho/test_echo.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import echo
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, *, text="hello", ephemeral_id=None):
|
||||
self.text = text
|
||||
self.caption = None
|
||||
self.chat_id = -1000000000002
|
||||
self.from_user = SimpleNamespace(id=1780243200)
|
||||
self.api_kwargs = {}
|
||||
if ephemeral_id is not None:
|
||||
self.api_kwargs["ephemeral_message_id"] = ephemeral_id
|
||||
self.replies = []
|
||||
|
||||
async def reply_text(self, text):
|
||||
self.replies.append(text)
|
||||
return SimpleNamespace(message_id=1, api_kwargs={})
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def send_message(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return SimpleNamespace(
|
||||
message_id=0,
|
||||
api_kwargs={"ephemeral_message_id": 88},
|
||||
)
|
||||
|
||||
|
||||
class EchoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_ephemeral_echo_uses_receiver_and_transient_reply(self):
|
||||
message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77)
|
||||
bot = FakeBot()
|
||||
|
||||
await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ")
|
||||
|
||||
self.assertEqual(message.replies, [])
|
||||
self.assertEqual(
|
||||
bot.calls,
|
||||
[{
|
||||
"chat_id": -1000000000002,
|
||||
"text": "ephemeral echo: /private@TetrisBot",
|
||||
"api_kwargs": {
|
||||
"receiver_user_id": 1780243200,
|
||||
"reply_parameters": {"ephemeral_message_id": 77},
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
async def test_normal_echo_stays_on_standard_reply_path(self):
|
||||
message = FakeMessage()
|
||||
bot = FakeBot()
|
||||
|
||||
await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ")
|
||||
|
||||
self.assertEqual(message.replies, ["echo: hello"])
|
||||
self.assertEqual(bot.calls, [])
|
||||
|
||||
def test_invalid_ephemeral_id_is_not_accepted(self):
|
||||
self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=0)))
|
||||
self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=True)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1042
cmd/giftfetch/main.go
Normal file
1042
cmd/giftfetch/main.go
Normal file
File diff suppressed because it is too large
Load diff
238
cmd/giftfetch/main_test.go
Normal file
238
cmd/giftfetch/main_test.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestHasRenderableStickerAttribute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []tg.DocumentAttributeClass
|
||||
want bool
|
||||
}{
|
||||
{name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true},
|
||||
{name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true},
|
||||
{name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want {
|
||||
t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"},
|
||||
{name: "", mime: "application/x-tgsticker", want: ".tgs"},
|
||||
{name: "unsafe.exe", mime: "video/webm", want: ".webm"},
|
||||
{name: "", mime: "application/octet-stream", want: ".bin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := documentExtension(test.name, test.mime); got != test.want {
|
||||
t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedBuffer(t *testing.T) {
|
||||
buffer := &boundedBuffer{max: 4}
|
||||
if _, err := buffer.Write([]byte("abc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written, err := buffer.Write([]byte("def")); err == nil || written != 1 {
|
||||
t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err)
|
||||
}
|
||||
if !bytes.Equal(buffer.Bytes(), []byte("abcd")) {
|
||||
t.Fatalf("buffer = %q, want abcd", buffer.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
size int64
|
||||
want int
|
||||
}{
|
||||
{size: 1, want: 4 << 10},
|
||||
{size: (4 << 10) - 1, want: 4 << 10},
|
||||
{size: 4 << 10, want: 8 << 10},
|
||||
{size: (512 << 10) - 1, want: 512 << 10},
|
||||
{size: 512 << 10, want: 512 << 10},
|
||||
{size: 1 << 20, want: 512 << 10},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := downloadPartSize(test.size); got != test.want {
|
||||
t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedMissingThumbs(t *testing.T) {
|
||||
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") {
|
||||
t.Fatalf("allowed = %v", allowed)
|
||||
}
|
||||
for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} {
|
||||
if _, err := parseAllowedMissingThumbs(invalid); err == nil {
|
||||
t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, reused, err := existingArtifact(root, "resource.bin", 4, 16)
|
||||
if err != nil || !reused || string(data) != "gift" {
|
||||
t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTLArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var encoded bin.Buffer
|
||||
if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded tg.PaymentsStarGiftUpgradeAttributes
|
||||
artifact, err := readTLArtifact(root, "attributes.tl", &decoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" {
|
||||
t.Fatalf("artifact = %+v", artifact)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil {
|
||||
t.Fatal("expected trailing-byte error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeableGiftIDs(t *testing.T) {
|
||||
classes := []tg.StarGiftClass{
|
||||
&tg.StarGift{ID: 1, UpgradeStars: 10},
|
||||
&tg.StarGift{ID: 2, UpgradeVariants: 3},
|
||||
&tg.StarGift{ID: 3},
|
||||
&tg.StarGiftUnique{ID: 4, GiftID: 1},
|
||||
}
|
||||
got := collectUpgradeableGiftIDs(classes)
|
||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributes(t *testing.T) {
|
||||
modelDoc := testGiftDocument(101)
|
||||
patternDoc := testGiftDocument(102)
|
||||
model := &tg.StarGiftAttributeModel{
|
||||
Name: "Crafted model",
|
||||
Document: modelDoc,
|
||||
Rarity: &tg.StarGiftAttributeRarityLegendary{},
|
||||
}
|
||||
model.SetCrafted(true)
|
||||
result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
model,
|
||||
&tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}},
|
||||
&tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}},
|
||||
}}
|
||||
added := make(map[int64]string)
|
||||
set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
|
||||
doc, ok := class.(*tg.Document)
|
||||
if !ok {
|
||||
return nil, errors.New("not a document")
|
||||
}
|
||||
added[doc.ID] = purpose
|
||||
return doc, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 {
|
||||
t.Fatalf("unexpected attribute counts: %+v", set)
|
||||
}
|
||||
if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" {
|
||||
t.Fatalf("model = %+v", set.Models[0])
|
||||
}
|
||||
if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 {
|
||||
t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity)
|
||||
}
|
||||
if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" {
|
||||
t.Fatalf("backdrop = %+v", set.Backdrops[0])
|
||||
}
|
||||
if len(set.DocumentIDs) != 2 || len(added) != 2 {
|
||||
t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) {
|
||||
_, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
&tg.StarGiftAttributeOriginalDetails{},
|
||||
}}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported-constructor error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRarityKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
class tg.StarGiftAttributeRarityClass
|
||||
kind string
|
||||
}{
|
||||
{class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"},
|
||||
{class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"},
|
||||
{class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"},
|
||||
{class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := collectRarity(test.class)
|
||||
if err != nil || got.Kind != test.kind || got.ConstructorID == "" {
|
||||
t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := collectRarity(nil); err == nil {
|
||||
t.Fatal("expected nil-rarity error")
|
||||
}
|
||||
}
|
||||
|
||||
func testGiftDocument(id int64) *tg.Document {
|
||||
return &tg.Document{
|
||||
ID: id,
|
||||
Size: 1,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Attributes: []tg.DocumentAttributeClass{
|
||||
&tg.DocumentAttributeCustomEmoji{Alt: "gift"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -134,23 +134,23 @@ type ChannelDetail struct {
|
|||
}
|
||||
|
||||
type StarGiftRow struct {
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
GiftID int64 `json:"GiftID,string"`
|
||||
RevisionID int64 `json:"RevisionID,string"`
|
||||
Revision int
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
Stars int64 `json:"Stars,string"`
|
||||
ConvertStars int64 `json:"ConvertStars,string"`
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
DocumentID int64
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
AnimationSHA string
|
||||
AnimationSize int64
|
||||
AnimationSize int64 `json:"AnimationSize,string"`
|
||||
Width int
|
||||
Height int
|
||||
FrameRate float64
|
||||
ReceivedCount int64
|
||||
ReceivedCount int64 `json:"ReceivedCount,string"`
|
||||
CreatedBy string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||
|
|
@ -70,6 +72,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
|
|
@ -228,6 +231,19 @@ func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Re
|
|||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
|
|
@ -696,10 +712,10 @@ type importStarGiftAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
|
@ -746,11 +762,48 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importOfficialStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importOfficialStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportOfficialStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
|
||||
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
|
||||
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
|
||||
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
|
||||
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
|
||||
|
|
@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
|
|
@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
|
|||
t.Fatalf("forwarded freeze request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(StarGiftRow{
|
||||
GiftID: maxInt64,
|
||||
RevisionID: maxInt64,
|
||||
Stars: maxInt64,
|
||||
ConvertStars: maxInt64,
|
||||
DocumentID: maxInt64,
|
||||
AnimationSize: maxInt64,
|
||||
ReceivedCount: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal star gift row: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal star gift row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} {
|
||||
if got[field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want exact decimal string", field, got[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
|
||||
"source_gift_id":"5895603153683874485",
|
||||
"gift_id":"9223372036854775807",
|
||||
"stars":"9223372036854775807",
|
||||
"convert_stars":"9223372036854775807",
|
||||
"upgrade_stars":"9223372036854775807"
|
||||
}`))
|
||||
var got importOfficialStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode gift action: %v", err)
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
|
||||
t.Fatalf("decoded gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.SetStarGiftEnabledRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{
|
||||
"reason":"precision regression","confirm":false,
|
||||
"gift_id":"9223372036854775807","enabled":false
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetStarGiftEnabledAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun {
|
||||
t.Fatalf("forwarded gift request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-D9dH2J7N.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-D9dH2J7N.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
|
||||
<script type="module" crossorigin src="/assets/index-D9dH2J7N.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
} from "./types";
|
||||
|
|
@ -66,11 +67,14 @@ export const api = {
|
|||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
|
||||
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
|
||||
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
|
||||
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
|
||||
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type Language = "en" | "zh";
|
||||
export type Language = "en" | "zh" | "ru";
|
||||
export type TranslationParams = Record<string, string | number | boolean>;
|
||||
export type TFunction = (key: string, params?: TranslationParams) => string;
|
||||
|
||||
|
|
@ -83,6 +83,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.logout": "Log out",
|
||||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"login.heading": "Operations Admin",
|
||||
"login.body": "Enter credentials to open the console.",
|
||||
"login.secret": "Admin password or token",
|
||||
|
|
@ -259,6 +260,26 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.importEyebrow": "Gift catalog operation",
|
||||
"gifts.newRevision": "Create revision for gift #{id}",
|
||||
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
|
||||
"gifts.officialSource": "Official snapshot",
|
||||
"gifts.fileSource": "Upload file",
|
||||
"gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.",
|
||||
"gifts.officialSearch": "Search official gift ID or title",
|
||||
"gifts.officialSelect": "Choose an official gift",
|
||||
"gifts.officialRequired": "Choose an official gift first",
|
||||
"gifts.officialResults": "Showing {shown} of {total}",
|
||||
"gifts.officialCategoryLabel": "Official gift capability category",
|
||||
"gifts.officialCategory.all": "All",
|
||||
"gifts.officialCategory.upgrade": "Upgradable",
|
||||
"gifts.officialCategory.craft": "Craftable",
|
||||
"gifts.officialCategory.basic": "Not upgradable",
|
||||
"gifts.officialUnnamed": "Unnamed official gift #{id}",
|
||||
"gifts.officialAttributes": "{count} attributes",
|
||||
"gifts.canUpgrade": "Can upgrade",
|
||||
"gifts.cannotUpgrade": "Cannot upgrade",
|
||||
"gifts.canCraft": "Can Craft",
|
||||
"gifts.cannotCraft": "Cannot Craft",
|
||||
"gifts.officialEmpty": "No official gifts match this category and search.",
|
||||
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
|
||||
"gifts.animation": "Animation file",
|
||||
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
|
||||
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
|
||||
|
|
@ -306,8 +327,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "Pattern",
|
||||
"collectibles.backdrop": "Backdrop",
|
||||
"collectibles.rarity": "Rarity ‰",
|
||||
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
|
||||
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
|
||||
"collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.",
|
||||
"collectibles.colorHint": "Colors are stored as 24-bit RGB values.",
|
||||
"collectibles.addAttribute": "Add",
|
||||
"collectibles.remove": "Remove attribute",
|
||||
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
|
||||
|
|
@ -432,6 +453,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.logout": "退出",
|
||||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"login.heading": "运维后台",
|
||||
"login.body": "输入凭据后进入控制台。",
|
||||
"login.secret": "管理员密码或 token",
|
||||
|
|
@ -608,6 +630,26 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.importEyebrow": "礼物目录操作",
|
||||
"gifts.newRevision": "为礼物 #{id} 创建新版本",
|
||||
"gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。",
|
||||
"gifts.officialSource": "官方资源库",
|
||||
"gifts.fileSource": "上传文件",
|
||||
"gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。",
|
||||
"gifts.officialSearch": "搜索官方礼物 ID 或标题",
|
||||
"gifts.officialSelect": "请选择官方礼物",
|
||||
"gifts.officialRequired": "请先选择一个官方礼物",
|
||||
"gifts.officialResults": "显示 {shown} / {total} 项",
|
||||
"gifts.officialCategoryLabel": "官方礼物能力分类",
|
||||
"gifts.officialCategory.all": "全部",
|
||||
"gifts.officialCategory.upgrade": "可升级",
|
||||
"gifts.officialCategory.craft": "可 Craft",
|
||||
"gifts.officialCategory.basic": "不可升级",
|
||||
"gifts.officialUnnamed": "未命名官方礼物 #{id}",
|
||||
"gifts.officialAttributes": "{count} 个属性",
|
||||
"gifts.canUpgrade": "可升级",
|
||||
"gifts.cannotUpgrade": "不可升级",
|
||||
"gifts.canCraft": "可 Craft",
|
||||
"gifts.cannotCraft": "不可 Craft",
|
||||
"gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。",
|
||||
"gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)",
|
||||
"gifts.animation": "动画文件",
|
||||
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
|
||||
"gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验",
|
||||
|
|
@ -655,8 +697,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "图案",
|
||||
"collectibles.backdrop": "背景",
|
||||
"collectibles.rarity": "稀有度 ‰",
|
||||
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
|
||||
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
|
||||
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
|
||||
"collectibles.colorHint": "颜色会按 24 位 RGB 数值保存。",
|
||||
"collectibles.addAttribute": "添加",
|
||||
"collectibles.remove": "删除属性",
|
||||
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
|
||||
|
|
@ -704,6 +746,376 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"audit.dryRun": "预演",
|
||||
"audit.reason": "原因",
|
||||
"audit.time": "时间"
|
||||
},
|
||||
ru: {
|
||||
"app.adminConsole": "Панель администратора",
|
||||
"app.localAccess": "Локальный доступ",
|
||||
"app.title": "telesrv admin",
|
||||
"common.actions": "Действия",
|
||||
"common.admins": "Администраторы",
|
||||
"common.backToList": "Назад к списку",
|
||||
"common.channel": "Канал",
|
||||
"common.channelOrGroup": "Канал / Группа",
|
||||
"common.clear": "Очистить",
|
||||
"common.close": "Закрыть",
|
||||
"common.count": "Количество",
|
||||
"common.deleted": "Удалено",
|
||||
"common.detail": "Детали",
|
||||
"common.device": "Устройство",
|
||||
"common.disabled": "Отключено",
|
||||
"common.enabled": "Включено",
|
||||
"common.fromPeer": "От пира",
|
||||
"common.group": "Группа",
|
||||
"common.id": "ID",
|
||||
"common.limit": "Лимит",
|
||||
"common.loading": "Загрузка...",
|
||||
"common.member": "Участник",
|
||||
"common.members": "Участники",
|
||||
"common.messageId": "ID сообщения",
|
||||
"common.name": "Имя",
|
||||
"common.no": "Нет",
|
||||
"common.noResults": "Нет результатов",
|
||||
"common.none": "Нет",
|
||||
"common.normal": "Обычный",
|
||||
"common.operations": "Операции",
|
||||
"common.owner": "Владелец",
|
||||
"common.platform": "Платформа",
|
||||
"common.refresh": "Обновить",
|
||||
"common.search": "Поиск",
|
||||
"common.sender": "Отправитель",
|
||||
"common.status": "Статус",
|
||||
"common.survived": "Уцелело",
|
||||
"common.time": "Время",
|
||||
"common.type": "Тип",
|
||||
"common.updatedAt": "Обновлено",
|
||||
"common.username": "Имя пользователя",
|
||||
"common.valid": "Действителен",
|
||||
"common.verified": "Подтвержден",
|
||||
"common.views": "Просмотры",
|
||||
"common.yes": "Да",
|
||||
"route.accounts": "Аккаунты",
|
||||
"route.accountsSubtitle": "Консоль / Аккаунты",
|
||||
"route.channels": "Супергруппы и каналы",
|
||||
"route.channelsSubtitle": "Консоль / Каналы",
|
||||
"route.dashboard": "Панель управления",
|
||||
"route.dashboardSubtitle": "Консоль / Обзор",
|
||||
"route.messages": "Аудит сообщений",
|
||||
"route.messagesSubtitle": "Консоль / Сообщения",
|
||||
"route.gifts": "Звездные подарки",
|
||||
"route.giftsSubtitle": "Консоль / Звездные подарки",
|
||||
"layout.navigation": "Навигация",
|
||||
"layout.primaryNav": "Основное меню",
|
||||
"layout.dashboard": "Обзор",
|
||||
"layout.accounts": "Аккаунты",
|
||||
"layout.channels": "Супергруппы / Каналы",
|
||||
"layout.messages": "Сообщения",
|
||||
"layout.gifts": "Звездные подарки",
|
||||
"layout.privateMessages": "Личные",
|
||||
"layout.groupMessages": "Группы",
|
||||
"layout.runtime": "Среда выполнения",
|
||||
"layout.adminBackend": "Админ-бэкенд",
|
||||
"layout.ready": "Готов",
|
||||
"layout.pgRead": "Чтение из PG",
|
||||
"layout.readOnly": "Только чтение",
|
||||
"layout.writeOps": "Операции записи",
|
||||
"layout.dryRun": "Тестовый запуск",
|
||||
"layout.actor": "Вход выполнен как: {actor}",
|
||||
"layout.logout": "Выйти",
|
||||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"login.heading": "Панель администратора",
|
||||
"login.body": "Введите учетные данные для входа в консоль.",
|
||||
"login.secret": "Пароль или токен администратора",
|
||||
"login.submit": "Войти",
|
||||
"login.submitting": "Вход...",
|
||||
"dashboard.eyebrow": "Состояние системы",
|
||||
"dashboard.title": "Обзор консоли",
|
||||
"dashboard.readPath": "Путь чтения",
|
||||
"dashboard.readPathValue": "PG только для чтения",
|
||||
"dashboard.writePath": "Путь записи",
|
||||
"dashboard.executionPolicy": "Политика выполнения",
|
||||
"dashboard.dryRunFirst": "Сначала тестовый запуск",
|
||||
"dashboard.accountsText": "Статус аккаунтов, премиум, верификация, сессии.",
|
||||
"dashboard.channelsText": "Публичные каналы и группы, количество участников, статус верификации.",
|
||||
"dashboard.messagesText": "Ящики сообщений, обновления, состояние исходящих.",
|
||||
"dashboard.strip.dryRun": "Все опасные действия начинаются с тестового запуска",
|
||||
"dashboard.strip.token": "Браузер никогда не сохраняет внутренние токены",
|
||||
"dashboard.strip.pagination": "Списки используют курсорную пагинацию",
|
||||
"dashboard.strip.snapshot": "Детальные страницы сохраняют моментальные снимки исходного состояния",
|
||||
"account.pageTitle": "Аккаунты",
|
||||
"account.queryResults": "Результаты поиска",
|
||||
"account.recentActive": "Недавно активные аккаунты",
|
||||
"account.currentPage": "Аккаунты на странице",
|
||||
"account.onlineDevices": "Активные сессии устройств",
|
||||
"account.premium": "Premium",
|
||||
"account.frozen": "Заморожен",
|
||||
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",
|
||||
"account.userID": "ID пользователя",
|
||||
"account.phone": "Телефон",
|
||||
"account.lastActive": "Последняя активность",
|
||||
"account.notVerified": "Не подтвержден",
|
||||
"account.notPremium": "Без Premium",
|
||||
"account.premiumUntil": "Premium истекает",
|
||||
"account.starsBalance": "Баланс Звезд",
|
||||
"account.startingGrantApplied": "стартовый бонус начислен",
|
||||
"account.startingGrantPending": "ожидает стартового бонуса",
|
||||
"account.activeSessions": "Авторизованные устройства",
|
||||
"account.accountFlags": "Флаги аккаунта",
|
||||
"account.restriction": "Ограничение",
|
||||
"account.restricted": "Ограничен",
|
||||
"account.createdAt": "Создан",
|
||||
"account.detailTitle": "Аккаунт #{id}",
|
||||
"account.profile": "Профиль аккаунта",
|
||||
"account.loadingDetail": "Загрузка данных аккаунта",
|
||||
"account.waitingData": "Ожидание данных",
|
||||
"account.noUsername": "Нет имени пользователя",
|
||||
"account.noPhone": "Нет телефона",
|
||||
"account.accountFrozen": "Аккаунт заморожен",
|
||||
"account.accountActive": "Аккаунт активен",
|
||||
"account.authorizationsTitle": "Авторизованные устройства",
|
||||
"account.authorizationsCount": "Авторизаций: {count}",
|
||||
"account.recentAdminOps": "Последние действия администратора",
|
||||
"account.recent30Audit": "Последние 30 записей аудита",
|
||||
"account.actionDock": "Действия с аккаунтом",
|
||||
"account.freezeAccount": "Заморозить аккаунт",
|
||||
"account.updateFreeze": "Обновить параметры заморозки",
|
||||
"account.unfreezeAccount": "Разморозить аккаунт",
|
||||
"account.freezeSince": "Заморожен с",
|
||||
"account.freezeUntil": "Срок подачи апелляции",
|
||||
"account.freezeUntilAria": "Срок подачи апелляции на заморозку",
|
||||
"account.freezeAppealURL": "URL для апелляции",
|
||||
"account.freezeAppealURLAria": "URL для апелляции на заморозку",
|
||||
"account.premiumMonths": "Срок действия Premium (в месяцах)",
|
||||
"account.premiumMonthsAria": "Указать срок действия Premium в месяцах",
|
||||
"account.setPremium": "Выдать Premium",
|
||||
"account.clearPremium": "Снять Premium",
|
||||
"account.starsAmount": "Количество звёзд",
|
||||
"account.starsAmountAria": "Указать количество начисляемых звёзд",
|
||||
"account.grantStars": "Начислить звёзды",
|
||||
"account.setVerified": "Подтвердить аккаунт",
|
||||
"account.clearVerified": "Снять подтверждение",
|
||||
"channel.pageTitle": "Супергруппы и каналы",
|
||||
"channel.recentUpdated": "Недавно обновленные",
|
||||
"channel.currentPage": "Объекты на странице",
|
||||
"channel.megagroups": "Супергруппы",
|
||||
"channel.broadcasts": "Каналы",
|
||||
"channel.verifiedCount": "Подтверждено",
|
||||
"channel.searchPlaceholder": "ID канала / имя пользователя / название",
|
||||
"channel.channelID": "ID канала",
|
||||
"channel.kind": "Тип",
|
||||
"channel.title": "Название",
|
||||
"channel.pts": "PTS",
|
||||
"channel.detailProfile": "Профиль канала",
|
||||
"channel.loadingDetail": "Загрузка данных канала",
|
||||
"channel.creator": "Создатель: {id}",
|
||||
"channel.governance": "Модерация",
|
||||
"channel.governanceValue": "Заблокировано {banned} / Исключено {kicked}",
|
||||
"channel.flags": "Флаги канала",
|
||||
"channel.rawRow": "Исходная строка БД",
|
||||
"channel.rawRowText": "Снимок базы данных только для чтения",
|
||||
"channel.actionDock": "Действия с каналом",
|
||||
"channel.setVerified": "Подтвердить канал",
|
||||
"channel.clearVerified": "Снять подтверждение",
|
||||
"channel.kind.broadcast": "Канал",
|
||||
"channel.kind.forum": "Супергруппа / Форум",
|
||||
"channel.kind.megagroup": "Супергруппа",
|
||||
"channel.kind.generic": "Канал / Группа",
|
||||
"messages.privateTitle": "Личные сообщения",
|
||||
"messages.privateEyebrow": "Личные ящики сообщений",
|
||||
"messages.groupTitle": "Групповые сообщения",
|
||||
"messages.groupEyebrow": "Сообщения супергрупп и каналов",
|
||||
"messages.selectPrivatePeers": "Сначала найдите и выберите владельца и собеседника",
|
||||
"messages.selectChannel": "Сначала найдите и выберите супергруппу или канал",
|
||||
"messages.ownerUser": "Пользователь-владелец",
|
||||
"messages.peerUser": "Собеседник",
|
||||
"messages.beforeDatePlaceholder": "курсор before_date",
|
||||
"messages.beforeIDPlaceholder": "курсор before_msg_id",
|
||||
"messages.limitPlaceholder": "лимит <= 100",
|
||||
"messages.searchMessages": "Поиск сообщений",
|
||||
"messages.nextPage": "Следующая страница",
|
||||
"messages.currentPage": "Сообщения на странице",
|
||||
"messages.deleted": "Удалено",
|
||||
"messages.outgoing": "Исходящее",
|
||||
"messages.incoming": "Входящее",
|
||||
"messages.ownerPeer": "Владелец / Собеседник",
|
||||
"messages.deleteSelected": "Указать и удалить выбранные сообщения",
|
||||
"messages.idsPlaceholder": "ID сообщений через запятую",
|
||||
"messages.revoke": "Удалить для обеих сторон",
|
||||
"messages.previewDelete": "Тестовое удаление",
|
||||
"messages.clearHistory": "Очистить историю личной переписки",
|
||||
"messages.maxIDPlaceholder": "граница max_id",
|
||||
"messages.maxBatchesPlaceholder": "max_batches",
|
||||
"messages.justClear": "Очистить только у себя",
|
||||
"messages.previewClearHistory": "Тестовая очистка истории",
|
||||
"messages.direction": "Направление",
|
||||
"messages.body": "Текст сообщения",
|
||||
"messages.privateDetailTitle": "Сообщение #{id}",
|
||||
"messages.detailEyebrow": "Детали сообщения",
|
||||
"messages.backPrivate": "Назад к личным сообщениям",
|
||||
"messages.backGroup": "Назад к групповым сообщениям",
|
||||
"messages.ownerPeerTitle": "Владелец {owner} · Собеседник {peer}",
|
||||
"messages.senderSubtitle": "Отправитель {sender} · {date}",
|
||||
"messages.boxID": "ID ящика сообщений",
|
||||
"messages.privateMessageID": "ID личного сообщения",
|
||||
"messages.messageSender": "Отправитель сообщения",
|
||||
"messages.messageBox": "Ящик сообщений",
|
||||
"messages.dialogRow": "Строка диалога",
|
||||
"messages.privateRow": "Строка личного сообщения",
|
||||
"messages.channelMessageRow": "Строка сообщения канала",
|
||||
"messages.channelRow": "Строка канала",
|
||||
"messages.userUpdateEvents": "События обновления пользователей",
|
||||
"messages.channelUpdateEvents": "События обновления каналов",
|
||||
"messages.eventJson": "JSON события",
|
||||
"messages.dispatchOutbox": "Очередь отправки (Outbox)",
|
||||
"messages.messageBoxesSnapshot": "Снимок message_boxes только для чтения",
|
||||
"messages.dialogSnapshot": "Снимок dialogs только для чтения",
|
||||
"messages.privateSnapshot": "Снимок private_messages только для чтения",
|
||||
"messages.channelMessagesSnapshot": "Снимок channel_messages только для чтения",
|
||||
"messages.channelSnapshot": "Снимок channels только для чтения",
|
||||
"messages.userEventsSource": "постоянные user_update_events",
|
||||
"messages.channelEventsSource": "постоянные channel_update_events",
|
||||
"messages.outboxSource": "онлайн/офлайн dispatch_outbox",
|
||||
"messages.attempts": "Попытки",
|
||||
"messages.deleteThis": "Удалить это сообщение",
|
||||
"messages.groupDetailTitle": "Групповое сообщение #{id}",
|
||||
"messages.channelGroupTitle": "Канал / Группа {id}",
|
||||
"messages.mediaCount": "С медиафайлами",
|
||||
"messages.channelPosts": "Посты канала",
|
||||
"messages.channelGroup": "Канал / Группа",
|
||||
"messages.pinned": "Закреплено",
|
||||
"messages.channelPost": "Пост в канале",
|
||||
"gifts.pageTitle": "Каталог звездных подарков",
|
||||
"gifts.eyebrow": "Каталог, неизменяемые версии и файлы анимаций",
|
||||
"gifts.total": "Подарков в каталоге",
|
||||
"gifts.enabled": "Включено",
|
||||
"gifts.received": "Полученные подарки",
|
||||
"gifts.formats": "Поддерживаемые форматы",
|
||||
"gifts.add": "Добавить подарок",
|
||||
"gifts.searchPlaceholder": "Поиск по ID подарка, названию или формату",
|
||||
"gifts.listSummary": "Показано {shown} из {total}",
|
||||
"gifts.idRevision": "ID / Версия",
|
||||
"gifts.price": "Цена / Конвертация",
|
||||
"gifts.importTitle": "Импорт звездного подарка",
|
||||
"gifts.importEyebrow": "Управление каталогом подарков",
|
||||
"gifts.newRevision": "Создать версию для подарка #{id}",
|
||||
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
|
||||
"gifts.officialSource": "Официальный снимок",
|
||||
"gifts.fileSource": "Загрузить файл",
|
||||
"gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.",
|
||||
"gifts.officialSearch": "Поиск по ID или названию официального подарка",
|
||||
"gifts.officialSelect": "Выберите официальный подарок",
|
||||
"gifts.officialRequired": "Сначала выберите официальный подарок",
|
||||
"gifts.officialResults": "Показано {shown} из {total}",
|
||||
"gifts.officialCategoryLabel": "Категория возможностей официального подарка",
|
||||
"gifts.officialCategory.all": "Все",
|
||||
"gifts.officialCategory.upgrade": "Можно улучшить",
|
||||
"gifts.officialCategory.craft": "Можно создать",
|
||||
"gifts.officialCategory.basic": "Нельзя улучшить",
|
||||
"gifts.officialUnnamed": "Официальный подарок без названия #{id}",
|
||||
"gifts.officialAttributes": "Атрибутов: {count}",
|
||||
"gifts.canUpgrade": "Можно улучшить",
|
||||
"gifts.cannotUpgrade": "Нельзя улучшить",
|
||||
"gifts.canCraft": "Можно создать",
|
||||
"gifts.cannotCraft": "Нельзя создать",
|
||||
"gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.",
|
||||
"gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели",
|
||||
"gifts.animation": "Файл анимации",
|
||||
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
|
||||
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
|
||||
"gifts.chooseFile": "Выбрать файл",
|
||||
"gifts.changeFile": "Изменить файл",
|
||||
"gifts.title": "Отображаемое название",
|
||||
"gifts.titlePlaceholder": "например, Праздничная звезда",
|
||||
"gifts.stars": "Цена в Звездах",
|
||||
"gifts.convertStars": "Звезд при конвертации",
|
||||
"gifts.sortOrder": "Порядок сортировки",
|
||||
"gifts.reason": "Причина для аудита",
|
||||
"gifts.reasonPlaceholder": "Кратко опишите причину импорта этого подарка",
|
||||
"gifts.enableAfterImport": "Включить после импорта",
|
||||
"gifts.validate": "Тестовая проверка",
|
||||
"gifts.confirmImport": "Подтвердить импорт",
|
||||
"gifts.stepDetails": "Файл и описание",
|
||||
"gifts.stepValidate": "Тестовая проверка",
|
||||
"gifts.stepImport": "Подтверждение импорта",
|
||||
"gifts.fileRequired": "Сначала выберите файл TGS или Lottie",
|
||||
"gifts.source": "Источник",
|
||||
"gifts.replace": "Новая версия",
|
||||
"gifts.disable": "Отключить",
|
||||
"gifts.enable": "Включить",
|
||||
"gifts.empty": "Звездные подарки еще не импортированы.",
|
||||
"gifts.emptyHint": "Импортируйте первую анимацию, чтобы начать наполнение каталога.",
|
||||
"gifts.validationReady": "Проверка пройдена",
|
||||
"gifts.validationHint": "Проверьте нормализованные метаданные и подтвердите импорт.",
|
||||
"gifts.confirmState": "Применить проверенные изменения состояния к подарку #{id}?",
|
||||
"collectibles.manage": "Пул атрибутов",
|
||||
"collectibles.title": "Пул коллекционных предметов · Подарок #{id}",
|
||||
"collectibles.eyebrow": "Уникальные атрибуты подарка",
|
||||
"collectibles.activeRevision": "Опубликованная версия {revision}",
|
||||
"collectibles.published": "Опубликовано",
|
||||
"collectibles.noPool": "Нет опубликованного пула коллекционных предметов",
|
||||
"collectibles.noPoolHint": "Опубликуйте модели, узоры и фоны для активации улучшений.",
|
||||
"collectibles.publishNew": "Опубликовать новую неизменяемую версию",
|
||||
"collectibles.immutableHint": "Тестовый запуск проверяет каждый файл и итоговые показатели редкости перед тем, как версия станет активной.",
|
||||
"collectibles.upgradeStars": "Цена улучшения в Звездах",
|
||||
"collectibles.supply": "Уникальный тираж",
|
||||
"collectibles.slug": "Публичный префикс ссылки (slug)",
|
||||
"collectibles.models": "Модели",
|
||||
"collectibles.patterns": "Узоры",
|
||||
"collectibles.backdrops": "Фоны",
|
||||
"collectibles.model": "Модель",
|
||||
"collectibles.pattern": "Узор",
|
||||
"collectibles.backdrop": "Фон",
|
||||
"collectibles.rarity": "Редкость ‰",
|
||||
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
|
||||
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения.",
|
||||
"collectibles.addAttribute": "Добавить",
|
||||
"collectibles.remove": "Удалить атрибут",
|
||||
"collectibles.fileRequired": "Для каждой модели и узора требуется файл TGS или Lottie.",
|
||||
"collectibles.backdropID": "ID фона",
|
||||
"collectibles.color.center": "Центр",
|
||||
"collectibles.color.edge": "Край",
|
||||
"collectibles.color.pattern": "Узор",
|
||||
"collectibles.color.text": "Текст",
|
||||
"collectibles.validationReady": "Пул атрибутов корректен",
|
||||
"collectibles.validationHint": "Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.",
|
||||
"collectibles.publish": "Опубликовать версию",
|
||||
"messages.msgIDsInvalid": "Некорректные ID сообщений",
|
||||
"auth.device": "Устройство",
|
||||
"auth.platform": "Платформа",
|
||||
"auth.ip": "IP-адрес",
|
||||
"auth.lastActive": "Последняя активность",
|
||||
"auth.revokeCurrent": "Отозвать текущую",
|
||||
"auth.keepCurrent": "Оставить текущую",
|
||||
"auth.revokeAll": "Разлогинить все устройства",
|
||||
"picker.userPlaceholder": "Поиск по user_id / телефону / имени пользователя",
|
||||
"picker.channelPlaceholder": "Поиск по channel_id / имени пользователя / названию",
|
||||
"picker.verified": "Подтвержденные",
|
||||
"picker.regular": "Обычные",
|
||||
"action.reasonRequired": "Пожалуйста, укажите причину операции",
|
||||
"action.flow": "Процесс выполнения",
|
||||
"action.close": "Закрыть",
|
||||
"action.stepReason": "Укажите причину",
|
||||
"action.stepDryRun": "Тестовый запуск",
|
||||
"action.stepConfirm": "Подтверждение выполнения",
|
||||
"action.reason": "Причина операции",
|
||||
"action.reasonPlaceholder": "Опишите, почему выполняется эта операция",
|
||||
"action.requestPreview": "Запросить предпросмотр",
|
||||
"action.result": "Результат действия",
|
||||
"action.commandID": "ID команды",
|
||||
"action.status": "Статус",
|
||||
"action.dryRun": "Тестовый запуск",
|
||||
"action.runAgain": "Запустить тестовый запуск снова",
|
||||
"action.runDry": "Сначала выполните тестовый запуск",
|
||||
"action.confirm": "Подтвердить выполнение",
|
||||
"audit.id": "ID",
|
||||
"audit.commandID": "ID команды",
|
||||
"audit.action": "Действие",
|
||||
"audit.actor": "Исполнитель",
|
||||
"audit.status": "Статус",
|
||||
"audit.dryRun": "Тестовый запуск",
|
||||
"audit.reason": "Причина",
|
||||
"audit.time": "Время"
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -724,7 +1136,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
|||
} catch {
|
||||
// Language persistence is best-effort.
|
||||
}
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
const langAttr = lang === "zh" ? "zh-CN" : lang === "ru" ? "ru" : "en";
|
||||
document.documentElement.lang = langAttr;
|
||||
document.documentElement.dir = "ltr";
|
||||
document.documentElement.setAttribute("translate", "no");
|
||||
document.body.classList.add("notranslate");
|
||||
|
|
@ -752,7 +1165,7 @@ export function LanguageSwitch() {
|
|||
const { lang, setLang, t } = useI18n();
|
||||
return (
|
||||
<div className="language-switch" role="group" aria-label="Language">
|
||||
{(["en", "zh"] as const).map((item) => (
|
||||
{(["en", "zh", "ru"] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={lang === item ? "active" : ""}
|
||||
|
|
@ -813,5 +1226,8 @@ function normalizeLanguage(value: string | null | undefined): Language | null {
|
|||
if (tag === "en" || tag.startsWith("en-")) {
|
||||
return "en";
|
||||
}
|
||||
if (tag === "ru" || tag.startsWith("ru-")) {
|
||||
return "ru";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp
|
|||
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
|
||||
}
|
||||
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
const [data, setData] = useState<AnimationData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
|
|||
}
|
||||
|
||||
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
|
||||
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -135,7 +136,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
|
||||
upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
|
||||
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
|
||||
backdrops: backdrops.map((row) => ({
|
||||
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
|
||||
|
|
@ -167,7 +168,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
<section className="collectible-section">
|
||||
<div className="collectible-section-head">
|
||||
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
</div>
|
||||
<div className="collectible-rows">
|
||||
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
|
||||
|
|
@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
|
||||
<div className="collectible-active-grid">
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
|
||||
|
||||
|
|
@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
{renderAnimatedRows("models", models, setModels)}
|
||||
{renderAnimatedRows("patterns", patterns, setPatterns)}
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,23 @@ import { ActionButton } from "../components/ActionButton";
|
|||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, StarGiftRow } from "../types";
|
||||
import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
|
||||
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) {
|
||||
function formatBytes(value: number | string) {
|
||||
const bytes = Number(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
|
@ -59,6 +66,20 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: number;
|
|||
);
|
||||
}
|
||||
|
||||
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.officialGiftAnimation(sourceGiftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [sourceGiftID]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const { t } = useI18n();
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
|
|
@ -66,7 +87,16 @@ export function GiftsPage() {
|
|||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [giftID, setGiftID] = useState(0);
|
||||
const [importSource, setImportSource] = useState<"official" | "file">("official");
|
||||
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
|
||||
const [officialQuery, setOfficialQuery] = useState("");
|
||||
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
|
||||
const [sourceGiftID, setSourceGiftID] = useState("");
|
||||
const [includeCollectible, setIncludeCollectible] = useState(true);
|
||||
const [upgradeStars, setUpgradeStars] = useState("0");
|
||||
const [supplyTotal, setSupplyTotal] = useState("0");
|
||||
const [slugPrefix, setSlugPrefix] = useState("");
|
||||
const [giftID, setGiftID] = useState("0");
|
||||
const [title, setTitle] = useState("");
|
||||
const [stars, setStars] = useState("50");
|
||||
const [convertStars, setConvertStars] = useState("50");
|
||||
|
|
@ -89,6 +119,29 @@ export function GiftsPage() {
|
|||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
|
||||
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, officialGifts.length]);
|
||||
|
||||
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
|
||||
const officialCategoryCounts = useMemo(() => ({
|
||||
all: officialGifts.length,
|
||||
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
|
||||
craft: officialGifts.filter((gift) => gift.can_craft).length,
|
||||
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
|
||||
}), [officialGifts]);
|
||||
const visibleOfficial = useMemo(() => {
|
||||
const normalized = officialQuery.trim().toLowerCase();
|
||||
return officialGifts.filter((gift) => {
|
||||
const categoryMatches = officialCategory === "all" ||
|
||||
(officialCategory === "upgrade" && gift.can_upgrade) ||
|
||||
(officialCategory === "craft" && gift.can_craft) ||
|
||||
(officialCategory === "basic" && !gift.can_upgrade);
|
||||
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
|
||||
});
|
||||
}, [officialGifts, officialQuery, officialCategory]);
|
||||
|
||||
const visibleGifts = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
|
|
@ -107,10 +160,10 @@ export function GiftsPage() {
|
|||
command_id: commandID,
|
||||
reason: reason.trim(),
|
||||
confirm,
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars: Number(stars),
|
||||
convert_stars: Number(convertStars),
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars,
|
||||
convert_stars: convertStars,
|
||||
enabled,
|
||||
sort_order: Number(sortOrder)
|
||||
}));
|
||||
|
|
@ -118,10 +171,34 @@ export function GiftsPage() {
|
|||
return form;
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
return {
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
|
||||
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
|
||||
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
|
||||
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function chooseOfficial(gift: OfficialStarGiftRow) {
|
||||
setSourceGiftID(gift.source_gift_id);
|
||||
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
|
||||
setStars(String(gift.stars));
|
||||
setConvertStars(String(gift.convert_stars));
|
||||
setIncludeCollectible(gift.can_upgrade);
|
||||
setUpgradeStars(gift.upgrade_stars);
|
||||
setSupplyTotal(String(gift.availability_total || 1));
|
||||
setSlugPrefix(`official-${gift.source_gift_id}`);
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
setPreview(await api.importGift(uploadForm(false)));
|
||||
setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
|
|
@ -131,8 +208,9 @@ export function GiftsPage() {
|
|||
if (!preview) return;
|
||||
setBusy(true); setImportError("");
|
||||
try {
|
||||
await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID(0); setTitle("");
|
||||
if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
|
||||
else await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID("");
|
||||
await load();
|
||||
setImportOpen(false);
|
||||
} catch (err) {
|
||||
|
|
@ -141,14 +219,16 @@ export function GiftsPage() {
|
|||
}
|
||||
|
||||
function startImport() {
|
||||
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
|
||||
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -160,7 +240,7 @@ export function GiftsPage() {
|
|||
<div className="metric-row gift-metrics">
|
||||
<Metric label={t("gifts.total")} value={String(gifts.length)} />
|
||||
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
|
||||
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
|
|
@ -193,24 +273,77 @@ export function GiftsPage() {
|
|||
|
||||
{importOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${(importSource === "official" ? sourceGiftID : file) ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : (importSource === "official" ? sourceGiftID : file) ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
|
||||
</div>
|
||||
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
<span className="gift-file-icon"><FileJson2 size={22} /></span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
</label>
|
||||
<div className="gift-source-tabs">
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
|
||||
</div>
|
||||
{importSource === "official" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
|
||||
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
|
||||
</div>
|
||||
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
|
||||
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
|
||||
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
|
||||
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
|
||||
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
|
||||
{visibleOfficial.map((gift) => {
|
||||
const selected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${selected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={selected} onClick={() => chooseOfficial(gift)}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
|
||||
<span className="mono">#{gift.source_gift_id}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>⭐ {gift.stars}</span>
|
||||
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
|
||||
</div>
|
||||
{selectedOfficial && <div className="official-gift-selected">
|
||||
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
|
||||
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
|
||||
</div>}
|
||||
{selectedOfficial?.can_upgrade && <>
|
||||
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
|
||||
{includeCollectible && <div className="gift-fields-grid">
|
||||
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
</div>}
|
||||
</>}
|
||||
</section> : <>
|
||||
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
<span className="gift-file-icon"><FileJson2 size={22} /></span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
</label>
|
||||
</>}
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
|
|
|
|||
|
|
@ -276,6 +276,60 @@
|
|||
|
||||
.gift-import-modal { width: min(860px, 100%); }
|
||||
.gift-import-modal-body { gap: 14px; }
|
||||
.gift-source-tabs { display: flex; gap: 8px; }
|
||||
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
|
||||
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.official-gift-tools .searchbox { width: 100%; }
|
||||
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }
|
||||
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.official-gift-categories button {
|
||||
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
|
||||
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
|
||||
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
|
||||
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
|
||||
.official-gift-categories button span {
|
||||
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
|
||||
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
|
||||
}
|
||||
.official-gift-categories button.active span { color: var(--brand); }
|
||||
.official-gift-list {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
|
||||
background: #f6f9f8; scrollbar-gutter: stable;
|
||||
}
|
||||
.official-gift-option {
|
||||
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
|
||||
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
|
||||
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
|
||||
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
|
||||
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.official-gift-capabilities > span {
|
||||
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
|
||||
}
|
||||
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
|
||||
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
|
||||
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
|
||||
.official-gift-empty {
|
||||
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
|
||||
color: var(--muted); text-align: center; font-size: 12px;
|
||||
}
|
||||
.official-gift-selected {
|
||||
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
|
||||
}
|
||||
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; }
|
||||
.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; }
|
||||
.official-gift-selected small { color: var(--muted); }
|
||||
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
|
||||
|
||||
.gift-file-picker {
|
||||
|
|
@ -321,6 +375,7 @@
|
|||
|
||||
.gift-fields-grid input,
|
||||
.gift-reason-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
|
|
@ -440,6 +495,10 @@
|
|||
.gift-file-action { display: none; }
|
||||
.gift-fields-grid { grid-template-columns: 1fr; }
|
||||
.gift-list-summary { width: 100%; margin-left: 0; }
|
||||
.official-gift-tools { align-items: stretch; flex-direction: column; }
|
||||
.official-gift-list { grid-template-columns: 1fr; max-height: 340px; }
|
||||
.official-gift-selected { grid-template-columns: 82px minmax(0, 1fr); }
|
||||
.official-gift-selected .gift-animation-shell { width: 72px; height: 72px; }
|
||||
.collectible-modal-body { padding: 10px; }
|
||||
.collectible-definition-head,
|
||||
.collectible-section-head { align-items: flex-start; flex-direction: column; }
|
||||
|
|
|
|||
|
|
@ -161,34 +161,58 @@ export type OutboxRow = {
|
|||
};
|
||||
|
||||
export type StarGiftRow = {
|
||||
GiftID: number;
|
||||
RevisionID: number;
|
||||
GiftID: string;
|
||||
RevisionID: string;
|
||||
Revision: number;
|
||||
Title: string;
|
||||
Stars: number;
|
||||
ConvertStars: number;
|
||||
Stars: string;
|
||||
ConvertStars: string;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
DocumentID: number;
|
||||
DocumentID: string;
|
||||
SourceName: string;
|
||||
SourceFormat: "tgs" | "lottie";
|
||||
AnimationSHA: string;
|
||||
AnimationSize: number;
|
||||
AnimationSize: string;
|
||||
Width: number;
|
||||
Height: number;
|
||||
FrameRate: number;
|
||||
ReceivedCount: number;
|
||||
ReceivedCount: string;
|
||||
CreatedBy: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
|
||||
|
||||
export type OfficialStarGiftRow = {
|
||||
source_gift_id: string;
|
||||
title: string;
|
||||
stars: string;
|
||||
convert_stars: string;
|
||||
upgrade_stars: string;
|
||||
availability_total: number;
|
||||
limited: boolean;
|
||||
sold_out: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_model_count: number;
|
||||
can_upgrade: boolean;
|
||||
can_craft: boolean;
|
||||
document_id: string;
|
||||
animation_validated: boolean;
|
||||
};
|
||||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: number;
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
name: string;
|
||||
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
rarity_permille: number;
|
||||
crafted: boolean;
|
||||
official_document_id: string;
|
||||
sort_order: number;
|
||||
source_name?: string;
|
||||
source_format?: "tgs" | "lottie";
|
||||
|
|
@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = {
|
|||
|
||||
export type StarGiftCollectiblePreview = {
|
||||
found: boolean;
|
||||
gift_id: number;
|
||||
gift_id: string;
|
||||
revision?: number;
|
||||
upgrade_stars?: number;
|
||||
upgrade_stars?: string;
|
||||
supply_total?: number;
|
||||
issued?: number;
|
||||
slug_prefix?: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -30,8 +30,10 @@ import (
|
|||
botsapp "telesrv/internal/app/bots"
|
||||
channelapp "telesrv/internal/app/channels"
|
||||
chatlistsapp "telesrv/internal/app/chatlists"
|
||||
communitiesapp "telesrv/internal/app/communities"
|
||||
"telesrv/internal/app/contacts"
|
||||
"telesrv/internal/app/dialogs"
|
||||
ephemeralapp "telesrv/internal/app/ephemeral"
|
||||
filesapp "telesrv/internal/app/files"
|
||||
groupcallsapp "telesrv/internal/app/groupcalls"
|
||||
"telesrv/internal/app/help"
|
||||
|
|
@ -56,6 +58,7 @@ import (
|
|||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
|
|
@ -361,6 +364,9 @@ func run(logger *zap.Logger) error {
|
|||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
||||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||
|
|
@ -384,6 +390,7 @@ func run(logger *zap.Logger) error {
|
|||
postgres.WithChannelMemberCache(channelMemberCache),
|
||||
postgres.WithChannelDialogCache(channelDialogCache),
|
||||
postgres.WithChannelBoostCache(channelBoostCache))
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
|
||||
pollStore := postgres.NewPollStore(pool)
|
||||
mediaStore := postgres.NewMediaStore(pool)
|
||||
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC
|
||||
|
|
@ -472,8 +479,9 @@ func run(logger *zap.Logger) error {
|
|||
rateLimiter := redisstore.NewRateLimiter(rdb)
|
||||
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
||||
adminService := adminapp.NewService(adminapp.Dependencies{
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
|
||||
})
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
|
|
@ -492,7 +500,7 @@ func run(logger *zap.Logger) error {
|
|||
cfg.UploadPartGCInterval,
|
||||
cfg.UploadPartGCBatch,
|
||||
).Run(ctx)
|
||||
langPackService := langpack.NewService(langPackStore)
|
||||
langPackService := langpack.NewService(langPackStore, langpack.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
privacyService := privacyapp.NewService(privacyStore, contactStore)
|
||||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||||
contacts.WithPhotoProvider(cachedPhotos),
|
||||
|
|
@ -529,6 +537,7 @@ func run(logger *zap.Logger) error {
|
|||
account.WithBusinessAutomation(passwordStore),
|
||||
account.WithUsers(userStore),
|
||||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
|
||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
account.WithEmailSignup(cfg.EmailSignupEnable),
|
||||
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
|
|
@ -676,9 +685,26 @@ func run(logger *zap.Logger) error {
|
|||
starsStore := postgres.NewStarsStore(pool)
|
||||
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
|
||||
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
|
||||
OfferMinStars: cfg.StarGiftOfferMinStars,
|
||||
ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second),
|
||||
ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second),
|
||||
CraftChancePermille: cfg.StarGiftCraftChancePermille,
|
||||
}))
|
||||
starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant,
|
||||
postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
||||
StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille,
|
||||
TONProceedsPermille: cfg.StarGiftTONProceedsPermille,
|
||||
}))
|
||||
starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init local star gift withdrawal provider: %w", err)
|
||||
}
|
||||
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore))
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore),
|
||||
stargifts.WithLifecycleStore(starGiftLifecycleStore),
|
||||
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -705,6 +731,8 @@ func run(logger *zap.Logger) error {
|
|||
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||
channelapp.WithSendPermissionChecker(adminService),
|
||||
)
|
||||
communitiesService := communitiesapp.NewService(communityStore)
|
||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||
chatlistsService := chatlistsapp.NewService(
|
||||
chatlistStore,
|
||||
dialogStore,
|
||||
|
|
@ -788,16 +816,21 @@ func run(logger *zap.Logger) error {
|
|||
),
|
||||
AccountFreeze: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
EphemeralReports: ephemeralReportStore,
|
||||
Users: usersService,
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
BotCallbacks: botCallbackStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
Bots: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
|
|
@ -872,7 +905,36 @@ func run(logger *zap.Logger) error {
|
|||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
go router.RunAccountLifecycle(ctx, time.Minute, 500)
|
||||
go func() {
|
||||
interval := cfg.StarGiftSweepInterval
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
batch := cfg.StarGiftSweepBatch
|
||||
if batch <= 0 {
|
||||
batch = 1000
|
||||
}
|
||||
run := func() {
|
||||
if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}()
|
||||
go router.RunInlineBotPushSubscriber(ctx)
|
||||
go router.RunBotCallbackAnswerSubscriber(ctx)
|
||||
go router.RunEphemeralPushSubscriber(ctx)
|
||||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
|
||||
return fmt.Errorf("start bot api: %w", err)
|
||||
}
|
||||
|
|
@ -880,17 +942,19 @@ func run(logger *zap.Logger) error {
|
|||
return fmt.Errorf("start admin api: %w", err)
|
||||
}
|
||||
if _, err := web.Start(ctx, web.Config{
|
||||
Addr: cfg.PublicLinkWebAddr,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
AppScheme: cfg.PublicAppScheme,
|
||||
WebBaseURL: cfg.PublicWebBaseURL,
|
||||
AppName: cfg.PublicAppName,
|
||||
DownloadURL: cfg.PublicDownloadURL,
|
||||
StickerSets: filesService,
|
||||
Users: userStore,
|
||||
Channels: channelStore,
|
||||
Privacy: privacyService,
|
||||
Photos: filesService,
|
||||
Addr: cfg.PublicLinkWebAddr,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
AppScheme: cfg.PublicAppScheme,
|
||||
WebBaseURL: cfg.PublicWebBaseURL,
|
||||
AppName: cfg.PublicAppName,
|
||||
DownloadURL: cfg.PublicDownloadURL,
|
||||
StickerSets: filesService,
|
||||
Users: userStore,
|
||||
Channels: channelStore,
|
||||
Privacy: privacyService,
|
||||
Photos: filesService,
|
||||
UniqueGifts: giftsService,
|
||||
GiftWithdrawals: giftsService,
|
||||
}, logger.Named("public-web")); err != nil {
|
||||
return fmt.Errorf("start public Web: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ type appServer struct {
|
|||
func main() {
|
||||
cfg := config{}
|
||||
flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address")
|
||||
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button")
|
||||
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telesrv menu button")
|
||||
flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL")
|
||||
flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>")
|
||||
flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue