Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -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
}

View 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 echoBot 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 @Botbot 回复显示
“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`

View 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())

View file

@ -0,0 +1 @@
aiogram==3.30.0

View 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()

View 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 写入文件。

View 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())

View file

@ -0,0 +1 @@
aiogram==3.30.0

View 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()

View file

@ -17,6 +17,21 @@ In a group with BotFather privacy enabled, send a command such as:
/ping hello from group
```
## Ephemeral echoBot 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 @Botbot 回复显示
“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`

View file

@ -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,

View 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

File diff suppressed because it is too large Load diff

238
cmd/giftfetch/main_test.go Normal file
View 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"},
},
}
}

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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)

View file

@ -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 JSONLottie 会规范化并压缩成 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;
}

View file

@ -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>)}

View file

@ -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>

View file

@ -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; }

View file

@ -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;

View file

@ -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)
}

View file

@ -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")