diff --git a/.env.example b/.env.example index 1bff6ef4..c0fd46e7 100644 --- a/.env.example +++ b/.env.example @@ -156,6 +156,23 @@ TELESRV_MAPBOX_TOKEN= TELESRV_MAPTILE_CACHE_DIR=data/maptiles TELESRV_LANGPACK_SEED_DIR=data/langpack +TELESRV_OFFICIAL_GIFTS_DIR=data/official-gifts +# Star Gift expiry/auction worker. TON values are handled by the local ledger; +# no wallet, Fragment or chain node endpoint is configured or contacted. +TELESRV_STARGIFT_SWEEP_INTERVAL=15s +TELESRV_STARGIFT_SWEEP_BATCH=1000 +# Internal nanoton granted once per user on first local-ledger access. +TELESRV_STARGIFT_TON_STARTING_GRANT=10000000000 +TELESRV_STARGIFT_TRANSFER_STARS=25 +TELESRV_STARGIFT_DROP_DETAILS_STARS=25 +TELESRV_STARGIFT_OFFER_MIN_STARS=1 +TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE=1000 +TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE=1000 +TELESRV_STARGIFT_EXPORT_DELAY=0s +TELESRV_STARGIFT_TRANSFER_DELAY=0s +TELESRV_STARGIFT_RESELL_DELAY=0s +TELESRV_STARGIFT_CRAFT_DELAY=0s +TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250 TELESRV_BLOB_DIR=data/blobs TELESRV_STICKER_SEED_DIR=data/sticker-seed diff --git a/README.zh-CN.md b/README.zh-CN.md index 8dbd5f44..5dc20858 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,7 +13,7 @@ **Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器**, 这个仓库就是可以运行、研究和共同优化的 server 侧实现。 -[English README](README.md) · [官网](https://telesrv.net) · [讨论群](https://t.me/telesrv_chat) · [频道](https://t.me/telesrv) +[English README](README.md) · [官网](https://telesrv.net) · [OwpenGram 客户端](https://owpengram.org/) · [讨论群](https://t.me/telesrv_chat) · [频道](https://t.me/telesrv) `gramsrv` 是独立的非官方项目,与 Telegram 官方及其团队没有关联,也未获得其背书或赞助。 @@ -29,6 +29,16 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c +## 客户端生态 + +`gramsrv` 是这一套生态里的 server 侧实现,而客户端项目会让自建 +Telegram-compatible 网络更容易被真实用户体验和验证。 + +我们也推荐关注 [OwpenGram](https://owpengram.org/)。它是一个支持多服务器的 +Telegram-style 客户端项目,可以在官方网络、私有自建服务器和社区节点之间切换。 +OwpenGram 内置了对 `gramsrv` server 的兼容支持,适合希望用同一个客户端连接多个 +Telegram-compatible server 的用户。 + ## 项目特性 | 状态 | 特性 | 说明 | diff --git a/cmd/appearancefetch/main.go b/cmd/appearancefetch/main.go index 5d5b1866..f6e95d01 100644 --- a/cmd/appearancefetch/main.go +++ b/cmd/appearancefetch/main.go @@ -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 } diff --git a/cmd/bots/aiogramecho/README.md b/cmd/bots/aiogramecho/README.md new file mode 100644 index 00000000..0febb3a7 --- /dev/null +++ b/cmd/bots/aiogramecho/README.md @@ -0,0 +1,78 @@ +# aiogram 3 echo demo + +该示例使用标准 aiogram 3 API,仅把 API server 指向 telesrv。aiogram 的 +`TelegramAPIServer.from_base()` 会自动拼出 telesrv 已支持的 +`/bot/` 与 `/file/bot/`。 + +```powershell +python -m pip install -r .\cmd\bots\aiogramecho\requirements.txt +$env:TELESRV_BOT_TOKEN = ":" +python .\cmd\bots\aiogramecho\echo.py --drop-pending +``` + +## Ephemeral echo(Bot API 10.2) + +先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,再在 TDesktop Layer 228 +的群组中发送: + +```text +/private@你的Bot用户名 +``` + +aiogram 3.30.0 原生解析 `ephemeral_message_id`。示例在 15 秒 action 窗口内携带 +`receiver_user_id` 和 `ReplyParameters(ephemeral_message_id=...)` 回复 +`ephemeral echo: ...`;失败不会降级成普通消息。Alice 应看到两条带 +可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示 +“Only visible to you”;Bob 不应看到其中任何一条。 + +发送三种语义色的 reply keyboard 与 inline callback 按钮: + +```powershell +python .\cmd\bots\aiogramecho\echo.py ` + --buttons-chat-id 1780243200 ` + --drop-pending +``` + +可选的 `--button-icon-id ` 同时验证按钮自定义 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`。 diff --git a/cmd/bots/aiogramecho/echo.py b/cmd/bots/aiogramecho/echo.py new file mode 100644 index 00000000..78b0d172 --- /dev/null +++ b/cmd/bots/aiogramecho/echo.py @@ -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 and /file/bot", + ) + 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 , /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()) diff --git a/cmd/bots/aiogramecho/requirements.txt b/cmd/bots/aiogramecho/requirements.txt new file mode 100644 index 00000000..1b94ec4e --- /dev/null +++ b/cmd/bots/aiogramecho/requirements.txt @@ -0,0 +1 @@ +aiogram==3.30.0 diff --git a/cmd/bots/aiogramecho/test_echo.py b/cmd/bots/aiogramecho/test_echo.py new file mode 100644 index 00000000..166e471d --- /dev/null +++ b/cmd/bots/aiogramecho/test_echo.py @@ -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() diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md new file mode 100644 index 00000000..32dfccb4 --- /dev/null +++ b/cmd/bots/bedolagaformat/README.md @@ -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 = ":" +$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 写入文件。 diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py new file mode 100644 index 00000000..a90746e5 --- /dev/null +++ b/cmd/bots/bedolagaformat/demo.py @@ -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"{marker} Default HTML " + "italic 😀 underline " + "spoiler " + 'link' + ), + # 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", + ) + 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"{marker} Start OK default HTML inherited 😀" + ) + 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()) diff --git a/cmd/bots/bedolagaformat/requirements.txt b/cmd/bots/bedolagaformat/requirements.txt new file mode 100644 index 00000000..1b94ec4e --- /dev/null +++ b/cmd/bots/bedolagaformat/requirements.txt @@ -0,0 +1 @@ +aiogram==3.30.0 diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py new file mode 100644 index 00000000..26530af2 --- /dev/null +++ b/cmd/bots/bedolagaformat/test_demo.py @@ -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("BEDOLAGA123 Default HTML", 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() diff --git a/cmd/bots/ptbecho/README.md b/cmd/bots/ptbecho/README.md index 72c8eb9a..50f64b9f 100644 --- a/cmd/bots/ptbecho/README.md +++ b/cmd/bots/ptbecho/README.md @@ -17,6 +17,21 @@ In a group with BotFather privacy enabled, send a command such as: /ping hello from group ``` +## Ephemeral echo(Bot API 10.2) + +先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,然后保持本示例轮询。 +在 TDesktop Layer 228 的群组里发送: + +```text +/private@你的Bot用户名 +``` + +本示例从 PTB 的 `Message.api_kwargs` 读取 `ephemeral_message_id`,并在 15 秒 action +窗口内用 `receiver_user_id` 与 `reply_parameters.ephemeral_message_id` 回复 +`ephemeral echo: ...`。请求失败时不会降级成普通群消息。Alice 应看到两条带 +可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示 +“Only visible to you”;Bob 不应看到其中任何一条。 + 主动发送一条消息并退出: ```powershell @@ -34,11 +49,24 @@ python .\cmd\bots\ptbecho\echo.py ` --send-text "ptbecho is online" ``` +发送 reply keyboard 与 inline callback 两条验证消息并保持 polling: + +```powershell +python .\cmd\bots\ptbecho\echo.py ` + --buttons-chat-id 1780243200 +``` + +reply keyboard 与 inline keyboard 都会各显示蓝/绿/红三种语义色;点击 reply +button 会按普通文本消息进入 echo 链,点击 inline button 会由 `callback_query` handler 调用 +`answerCallbackQuery` 并显示 `telesrv inline callback OK`。也可以在私聊中发送 +`/buttons` 生成同样的两条消息。 + 可选参数: - `--send-count N`:连续主动发送 N 条。 - `--send-interval SEC`:连续发送之间的间隔。 - `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。 +- `--buttons-chat-id` / `TELESRV_BOT_DEMO_BUTTONS_CHAT_ID`:发送两类键盘验证消息并监听 callback。 本地超级群 chat id 使用 Bot API 形式 `-100`;例如 channel id 为 `2` 时是 `-1000000000002`。 diff --git a/cmd/bots/ptbecho/echo.py b/cmd/bots/ptbecho/echo.py index 6e3d5a84..82bf75f8 100644 --- a/cmd/bots/ptbecho/echo.py +++ b/cmd/bots/ptbecho/echo.py @@ -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, diff --git a/cmd/bots/ptbecho/test_echo.py b/cmd/bots/ptbecho/test_echo.py new file mode 100644 index 00000000..3fe4a9a6 --- /dev/null +++ b/cmd/bots/ptbecho/test_echo.py @@ -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() diff --git a/cmd/giftfetch/main.go b/cmd/giftfetch/main.go new file mode 100644 index 00000000..7736ab6e --- /dev/null +++ b/cmd/giftfetch/main.go @@ -0,0 +1,1042 @@ +// Command giftfetch snapshots the official Telegram star-gift catalog, the +// complete current upgrade-attribute pools, and all document resources +// referenced by either response. It is a read-only fetcher: it never imports +// data into telesrv and never copies the authorization session. +// +// Usage: +// +// SESSION=/path/to/session giftfetch -out +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/telegram" + "github.com/iamxvbaba/td/telegram/downloader" + "github.com/iamxvbaba/td/tg" + "golang.org/x/sync/errgroup" + + "telesrv/internal/app/stargifts" +) + +const ( + apiID = 17349 + apiHash = "344583e45741c457fe1862106095a5eb" + defaultMaxDocBytes = int64(16 << 20) + defaultWorkers = 8 + maxCatalogGifts = 5000 + maxUpgradeAttrs = 10000 + maxDocuments = 50000 + maxWorkers = 128 + floodWaitMargin = 5 * time.Second +) + +type catalogManifest struct { + Schema int `json:"schema"` + Hash int `json:"hash"` + RawCatalog fileArtifact `json:"raw_catalog"` + GiftCount int `json:"gift_count"` + ChatCount int `json:"chat_count"` + UserCount int `json:"user_count"` + UpgradeableGiftCount int `json:"upgradeable_gift_count"` + UpgradeAttributeSetCount int `json:"upgrade_attribute_set_count"` + UpgradeAttributeCount int `json:"upgrade_attribute_count"` + UpgradeModelCount int `json:"upgrade_model_count"` + UpgradePatternCount int `json:"upgrade_pattern_count"` + UpgradeBackdropCount int `json:"upgrade_backdrop_count"` + MissingThumbCount int `json:"missing_thumb_count"` + Gifts []giftManifest `json:"gifts"` + UpgradeAttributeSets []upgradeAttributeSetManifest `json:"upgrade_attribute_sets"` + Documents []documentManifest `json:"documents"` + TotalBytes int64 `json:"total_document_bytes"` + BoundaryNote string `json:"boundary_note"` +} + +type giftManifest struct { + Index int `json:"index"` + Kind string `json:"kind"` + ID int64 `json:"id"` + GiftID int64 `json:"gift_id,omitempty"` + Title string `json:"title,omitempty"` + Slug string `json:"slug,omitempty"` + Number int `json:"number,omitempty"` + Stars int64 `json:"stars,omitempty"` + ConvertStars int64 `json:"convert_stars,omitempty"` + UpgradeStars int64 `json:"upgrade_stars,omitempty"` + ResellMinStars int64 `json:"resell_min_stars,omitempty"` + Limited bool `json:"limited,omitempty"` + SoldOut bool `json:"sold_out,omitempty"` + Birthday bool `json:"birthday,omitempty"` + RequirePremium bool `json:"require_premium,omitempty"` + LimitedPerUser bool `json:"limited_per_user,omitempty"` + PeerColorAvailable bool `json:"peer_color_available,omitempty"` + Auction bool `json:"auction,omitempty"` + AvailabilityRemains int `json:"availability_remains,omitempty"` + AvailabilityTotal int `json:"availability_total,omitempty"` + AvailabilityResale int64 `json:"availability_resale,omitempty"` + AvailabilityIssued int `json:"availability_issued,omitempty"` + PerUserTotal int `json:"per_user_total,omitempty"` + PerUserRemains int `json:"per_user_remains,omitempty"` + FirstSaleDate int `json:"first_sale_date,omitempty"` + LastSaleDate int `json:"last_sale_date,omitempty"` + LockedUntilDate int `json:"locked_until_date,omitempty"` + AuctionSlug string `json:"auction_slug,omitempty"` + GiftsPerRound int `json:"gifts_per_round,omitempty"` + AuctionStartDate int `json:"auction_start_date,omitempty"` + UpgradeVariants int `json:"upgrade_variants,omitempty"` + DocumentIDs []int64 `json:"document_ids,omitempty"` + Background *backgroundManifest `json:"background,omitempty"` +} + +type backgroundManifest struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + TextColor int `json:"text_color"` +} + +type upgradeAttributeSetManifest struct { + GiftID int64 `json:"gift_id"` + RawAttributes fileArtifact `json:"raw_attributes"` + AttributeCount int `json:"attribute_count"` + Models []upgradeModelManifest `json:"models"` + Patterns []upgradePatternManifest `json:"patterns"` + Backdrops []upgradeBackdropManifest `json:"backdrops"` + DocumentIDs []int64 `json:"document_ids"` +} + +type upgradeModelManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Crafted bool `json:"crafted"` + Rarity rarityManifest `json:"rarity"` +} + +type upgradePatternManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Rarity rarityManifest `json:"rarity"` +} + +type upgradeBackdropManifest struct { + Name string `json:"name"` + BackdropID int `json:"backdrop_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` + Rarity rarityManifest `json:"rarity"` +} + +type rarityManifest struct { + Kind string `json:"kind"` + ConstructorID string `json:"constructor_id"` + Permille *int `json:"permille,omitempty"` +} + +type documentManifest struct { + ID int64 `json:"id"` + Date int `json:"date"` + DCID int `json:"dc_id"` + MimeType string `json:"mime_type"` + ExpectedSize int64 `json:"expected_size"` + FileName string `json:"file_name,omitempty"` + StickerAlt string `json:"sticker_alt,omitempty"` + Purposes []string `json:"purposes"` + File fileArtifact `json:"file"` + AnimationValidated bool `json:"animation_validated,omitempty"` + ValidationError string `json:"validation_error,omitempty"` + Thumbs []fileArtifact `json:"thumbs,omitempty"` + MissingThumbs []missingThumb `json:"missing_thumbs,omitempty"` +} + +type missingThumb struct { + Kind string `json:"kind"` + Type string `json:"type"` + ExpectedSize int64 `json:"expected_size"` + Error string `json:"error"` +} + +type fileArtifact struct { + Kind string `json:"kind,omitempty"` + Type string `json:"type,omitempty"` + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type documentSource struct { + document *tg.Document + purposes map[string]struct{} +} + +type addDocumentFunc func(tg.DocumentClass, string) (*tg.Document, error) + +func main() { + outDir := flag.String("out", "", "output directory") + maxDocBytes := flag.Int64("max-document-bytes", defaultMaxDocBytes, "maximum bytes accepted for one document or thumbnail") + skipThumbs := flag.Bool("skip-thumbs", false, "download main documents only") + workers := flag.Int("workers", defaultWorkers, "concurrent document downloads") + reuseMetadata := flag.Bool("reuse-metadata", false, "reuse and strictly decode catalog.tl plus upgrade-attributes/*.tl instead of refetching metadata") + allowedMissingThumbsRaw := flag.String("allow-missing-thumb", "", "comma-separated document_id:photo|video:type entries that may be recorded as explicitly missing after a failed download") + flag.Parse() + if strings.TrimSpace(*outDir) == "" { + fmt.Fprintln(os.Stderr, "usage: SESSION=/path/to/session giftfetch -out ") + os.Exit(2) + } + session := strings.TrimSpace(os.Getenv("SESSION")) + if session == "" { + fmt.Fprintln(os.Stderr, "ERROR: SESSION is required") + os.Exit(2) + } + if *maxDocBytes <= 0 || *maxDocBytes > 256<<20 { + fmt.Fprintln(os.Stderr, "ERROR: max-document-bytes must be in (0, 256 MiB]") + os.Exit(2) + } + if *workers <= 0 || *workers > maxWorkers { + fmt.Fprintf(os.Stderr, "ERROR: workers must be in [1, %d]\n", maxWorkers) + os.Exit(2) + } + allowedMissingThumbs, err := parseAllowedMissingThumbs(*allowedMissingThumbsRaw) + if err != nil { + fmt.Fprintln(os.Stderr, "ERROR:", err) + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour) + defer cancel() + client := telegram.NewClient(apiID, apiHash, telegram.Options{ + SessionStorage: &telegram.FileSessionStorage{Path: session}, + }) + if err := client.Run(ctx, func(ctx context.Context) error { + status, err := client.Auth().Status(ctx) + if err != nil { + return fmt.Errorf("auth status: %w", err) + } + if !status.Authorized { + return errors.New("SESSION is not authorized") + } + fmt.Println("[session authorized]") + return fetchCatalog(ctx, client.API(), *outDir, *maxDocBytes, *skipThumbs, *workers, *reuseMetadata, allowedMissingThumbs) + }); err != nil { + fmt.Fprintln(os.Stderr, "ERROR:", err) + os.Exit(1) + } +} + +func fetchCatalog(ctx context.Context, api *tg.Client, outDir string, maxDocBytes int64, skipThumbs bool, workers int, reuseMetadata bool, allowedMissingThumbs map[string]struct{}) error { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + var catalog *tg.PaymentsStarGifts + var rawArtifact fileArtifact + if reuseMetadata { + catalog = &tg.PaymentsStarGifts{} + var err error + rawArtifact, err = readTLArtifact(outDir, "catalog.tl", catalog) + if err != nil { + return fmt.Errorf("reuse catalog metadata: %w", err) + } + fmt.Printf("[metadata reused] path=%s\n", rawArtifact.Path) + } else { + result, err := api.PaymentsGetStarGifts(ctx, 0) + if err != nil { + return fmt.Errorf("payments.getStarGifts: %w", err) + } + var ok bool + catalog, ok = result.(*tg.PaymentsStarGifts) + if !ok { + return fmt.Errorf("payments.getStarGifts(hash=0) returned %T", result) + } + var raw bin.Buffer + if err := catalog.Encode(&raw); err != nil { + return fmt.Errorf("encode raw catalog: %w", err) + } + rawArtifact, err = writeArtifact(outDir, "catalog.tl", "tl", "", raw.Buf) + if err != nil { + return err + } + } + if len(catalog.Gifts) > maxCatalogGifts { + return fmt.Errorf("gift catalog has %d entries, limit is %d", len(catalog.Gifts), maxCatalogGifts) + } + manifest := catalogManifest{ + Schema: 2, + Hash: catalog.Hash, + RawCatalog: rawArtifact, + GiftCount: len(catalog.Gifts), + ChatCount: len(catalog.Chats), + UserCount: len(catalog.Users), + BoundaryNote: "payments.getStarGifts(hash=0) plus payments.getStarGiftUpgradeAttributes for every currently upgradeable base gift: complete current official attribute definitions and referenced documents, not deleted historical definitions or precomputed model-pattern-backdrop combinations", + } + + documents := make(map[int64]*documentSource) + addDocument := func(class tg.DocumentClass, purpose string) (*tg.Document, error) { + doc, ok := class.(*tg.Document) + if !ok || doc.ID == 0 { + return nil, fmt.Errorf("%s references invalid document %T", purpose, class) + } + if !hasRenderableStickerAttribute(doc) { + return nil, fmt.Errorf("%s document %d has neither sticker nor custom-emoji attribute", purpose, doc.ID) + } + existing := documents[doc.ID] + if existing == nil { + existing = &documentSource{document: doc, purposes: make(map[string]struct{})} + documents[doc.ID] = existing + } else if existing.document.Size != doc.Size || existing.document.MimeType != doc.MimeType { + return nil, fmt.Errorf("document %d has conflicting metadata", doc.ID) + } + existing.purposes[purpose] = struct{}{} + return doc, nil + } + + for index, class := range catalog.Gifts { + gm, err := collectGift(index, class, addDocument) + if err != nil { + return err + } + manifest.Gifts = append(manifest.Gifts, gm) + } + + upgradeableGiftIDs := collectUpgradeableGiftIDs(catalog.Gifts) + manifest.UpgradeableGiftCount = len(upgradeableGiftIDs) + for _, giftID := range upgradeableGiftIDs { + attributeSet, err := fetchUpgradeAttributeSet(ctx, api, outDir, giftID, addDocument, reuseMetadata) + if err != nil { + return err + } + manifest.UpgradeAttributeSets = append(manifest.UpgradeAttributeSets, attributeSet) + manifest.UpgradeAttributeSetCount++ + manifest.UpgradeAttributeCount += attributeSet.AttributeCount + manifest.UpgradeModelCount += len(attributeSet.Models) + manifest.UpgradePatternCount += len(attributeSet.Patterns) + manifest.UpgradeBackdropCount += len(attributeSet.Backdrops) + fmt.Printf("[upgrade attributes] gift_id=%d models=%d patterns=%d backdrops=%d raw=%s\n", giftID, len(attributeSet.Models), len(attributeSet.Patterns), len(attributeSet.Backdrops), attributeSet.RawAttributes.Path) + } + if manifest.UpgradeAttributeSetCount != manifest.UpgradeableGiftCount { + return fmt.Errorf("upgrade attribute set count mismatch: got %d want %d", manifest.UpgradeAttributeSetCount, manifest.UpgradeableGiftCount) + } + if len(documents) > maxDocuments { + return fmt.Errorf("catalog and upgrade attributes reference %d documents, limit is %d", len(documents), maxDocuments) + } + + ids := make([]int64, 0, len(documents)) + for id := range documents { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + fmt.Printf("[documents discovered] count=%d workers=%d\n", len(ids), workers) + documentResults := make([]documentManifest, len(ids)) + group, downloadCtx := errgroup.WithContext(ctx) + group.SetLimit(workers) + var completed atomic.Int64 + for index, id := range ids { + index, id := index, id + group.Go(func() error { + dl := downloader.NewDownloader().WithRetryHandler(func(event downloader.RetryEvent) { + fmt.Printf("[download retry] document=%d operation=%s attempt=%d error=%v\n", id, event.Operation, event.Attempt, event.Err) + if strings.Contains(event.Err.Error(), "FLOOD_WAIT") { + timer := time.NewTimer(floodWaitMargin) + defer timer.Stop() + select { + case <-downloadCtx.Done(): + case <-timer.C: + } + } + }) + dm, err := fetchDocument(downloadCtx, api, dl, outDir, documents[id], maxDocBytes, skipThumbs, allowedMissingThumbs) + if err != nil { + return err + } + documentResults[index] = dm + done := completed.Add(1) + if done == int64(len(ids)) || done%100 == 0 { + fmt.Printf("[documents downloaded] completed=%d total=%d\n", done, len(ids)) + } + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for _, dm := range documentResults { + manifest.TotalBytes += dm.File.Size + for _, thumb := range dm.Thumbs { + manifest.TotalBytes += thumb.Size + } + manifest.MissingThumbCount += len(dm.MissingThumbs) + manifest.Documents = append(manifest.Documents, dm) + } + + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + if err := writeFileAtomic(filepath.Join(outDir, "manifest.json"), append(encoded, '\n')); err != nil { + return err + } + fmt.Printf("[complete] gifts=%d attribute_sets=%d attributes=%d documents=%d missing_thumbs=%d bytes=%d manifest=%s\n", len(manifest.Gifts), len(manifest.UpgradeAttributeSets), manifest.UpgradeAttributeCount, len(manifest.Documents), manifest.MissingThumbCount, manifest.TotalBytes, filepath.Join(outDir, "manifest.json")) + return nil +} + +func collectUpgradeableGiftIDs(classes []tg.StarGiftClass) []int64 { + ids := make([]int64, 0, len(classes)) + for _, class := range classes { + gift, ok := class.(*tg.StarGift) + if !ok || gift.ID == 0 || gift.UpgradeStars <= 0 && gift.UpgradeVariants <= 0 { + continue + } + ids = append(ids, gift.ID) + } + return ids +} + +func fetchUpgradeAttributeSet(ctx context.Context, api *tg.Client, root string, giftID int64, addDocument addDocumentFunc, reuseMetadata bool) (upgradeAttributeSetManifest, error) { + var result *tg.PaymentsStarGiftUpgradeAttributes + var rawArtifact fileArtifact + if reuseMetadata { + result = &tg.PaymentsStarGiftUpgradeAttributes{} + var err error + rawArtifact, err = readTLArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), result) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("reuse upgrade attributes for gift %d: %w", giftID, err) + } + } else { + var err error + result, err = api.PaymentsGetStarGiftUpgradeAttributes(ctx, giftID) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d): %w", giftID, err) + } + var raw bin.Buffer + if err := result.Encode(&raw); err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("encode upgrade attributes for gift %d: %w", giftID, err) + } + rawArtifact, err = writeArtifact(root, filepath.Join("upgrade-attributes", fmt.Sprintf("%d.tl", giftID)), "tl", "", raw.Buf) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + } + if len(result.Attributes) == 0 { + return upgradeAttributeSetManifest{}, fmt.Errorf("payments.getStarGiftUpgradeAttributes(gift_id=%d) returned no attributes", giftID) + } + if len(result.Attributes) > maxUpgradeAttrs { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has %d upgrade attributes, limit is %d", giftID, len(result.Attributes), maxUpgradeAttrs) + } + return collectUpgradeAttributes(giftID, result, rawArtifact, addDocument) +} + +func collectUpgradeAttributes(giftID int64, result *tg.PaymentsStarGiftUpgradeAttributes, rawArtifact fileArtifact, addDocument addDocumentFunc) (upgradeAttributeSetManifest, error) { + if result == nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d has nil upgrade-attribute result", giftID) + } + set := upgradeAttributeSetManifest{ + GiftID: giftID, + RawAttributes: rawArtifact, + AttributeCount: len(result.Attributes), + } + documentIDs := make(map[int64]struct{}) + for index, attribute := range result.Attributes { + switch value := attribute.(type) { + case *tg.StarGiftAttributeModel: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d model %q rarity: %w", giftID, value.Name, err) + } + doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-model:%s", giftID, value.Name)) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + set.Models = append(set.Models, upgradeModelManifest{Name: value.Name, DocumentID: doc.ID, Crafted: value.GetCrafted(), Rarity: rarity}) + documentIDs[doc.ID] = struct{}{} + case *tg.StarGiftAttributePattern: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d pattern %q rarity: %w", giftID, value.Name, err) + } + doc, err := addDocument(value.Document, fmt.Sprintf("gift:%d:upgrade-pattern:%s", giftID, value.Name)) + if err != nil { + return upgradeAttributeSetManifest{}, err + } + set.Patterns = append(set.Patterns, upgradePatternManifest{Name: value.Name, DocumentID: doc.ID, Rarity: rarity}) + documentIDs[doc.ID] = struct{}{} + case *tg.StarGiftAttributeBackdrop: + rarity, err := collectRarity(value.Rarity) + if err != nil { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d backdrop %q rarity: %w", giftID, value.Name, err) + } + set.Backdrops = append(set.Backdrops, upgradeBackdropManifest{ + Name: value.Name, BackdropID: value.BackdropID, CenterColor: value.CenterColor, + EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor, + Rarity: rarity, + }) + default: + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d upgrade attribute at index %d has unsupported constructor %T", giftID, index, attribute) + } + } + if len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d incomplete upgrade attributes: models=%d patterns=%d backdrops=%d", giftID, len(set.Models), len(set.Patterns), len(set.Backdrops)) + } + if len(set.Models)+len(set.Patterns)+len(set.Backdrops) != set.AttributeCount { + return upgradeAttributeSetManifest{}, fmt.Errorf("gift %d parsed %d of %d upgrade attributes", giftID, len(set.Models)+len(set.Patterns)+len(set.Backdrops), set.AttributeCount) + } + for id := range documentIDs { + set.DocumentIDs = append(set.DocumentIDs, id) + } + sort.Slice(set.DocumentIDs, func(i, j int) bool { return set.DocumentIDs[i] < set.DocumentIDs[j] }) + return set, nil +} + +func collectRarity(class tg.StarGiftAttributeRarityClass) (rarityManifest, error) { + switch value := class.(type) { + case *tg.StarGiftAttributeRarity: + permille := value.Permille + return rarityManifest{Kind: "permille", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID()), Permille: &permille}, nil + case *tg.StarGiftAttributeRarityUncommon: + return rarityManifest{Kind: "uncommon", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityRare: + return rarityManifest{Kind: "rare", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityEpic: + return rarityManifest{Kind: "epic", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + case *tg.StarGiftAttributeRarityLegendary: + return rarityManifest{Kind: "legendary", ConstructorID: fmt.Sprintf("0x%08x", value.TypeID())}, nil + default: + return rarityManifest{}, fmt.Errorf("unsupported constructor %T", class) + } +} + +func collectGift(index int, class tg.StarGiftClass, addDocument addDocumentFunc) (giftManifest, error) { + switch gift := class.(type) { + case *tg.StarGift: + purpose := fmt.Sprintf("gift:%d:sticker", gift.ID) + doc, err := addDocument(gift.Sticker, purpose) + if err != nil { + return giftManifest{}, err + } + gm := giftManifest{ + Index: index, + Kind: "regular", + ID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, + ResellMinStars: gift.ResellMinStars, + Limited: gift.Limited, + SoldOut: gift.SoldOut, + Birthday: gift.Birthday, + RequirePremium: gift.RequirePremium, + LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, + Auction: gift.Auction, + AvailabilityRemains: gift.AvailabilityRemains, + AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityResale: gift.AvailabilityResale, + PerUserTotal: gift.PerUserTotal, + PerUserRemains: gift.PerUserRemains, + FirstSaleDate: gift.FirstSaleDate, + LastSaleDate: gift.LastSaleDate, + LockedUntilDate: gift.LockedUntilDate, + AuctionSlug: gift.AuctionSlug, + GiftsPerRound: gift.GiftsPerRound, + AuctionStartDate: gift.AuctionStartDate, + UpgradeVariants: gift.UpgradeVariants, + Title: gift.Title, + DocumentIDs: []int64{doc.ID}, + } + if background, ok := gift.GetBackground(); ok { + gm.Background = &backgroundManifest{CenterColor: background.CenterColor, EdgeColor: background.EdgeColor, TextColor: background.TextColor} + } + return gm, nil + case *tg.StarGiftUnique: + gm := giftManifest{ + Index: index, + Kind: "unique", + ID: gift.ID, + GiftID: gift.GiftID, + Title: gift.Title, + Slug: gift.Slug, + Number: gift.Num, + RequirePremium: gift.RequirePremium, + AvailabilityIssued: gift.AvailabilityIssued, + AvailabilityTotal: gift.AvailabilityTotal, + } + for _, attribute := range gift.Attributes { + var class tg.DocumentClass + var purpose string + switch value := attribute.(type) { + case *tg.StarGiftAttributeModel: + class = value.Document + purpose = fmt.Sprintf("unique:%d:model:%s", gift.ID, value.Name) + case *tg.StarGiftAttributePattern: + class = value.Document + purpose = fmt.Sprintf("unique:%d:pattern:%s", gift.ID, value.Name) + default: + continue + } + doc, err := addDocument(class, purpose) + if err != nil { + return giftManifest{}, err + } + gm.DocumentIDs = append(gm.DocumentIDs, doc.ID) + } + return gm, nil + default: + return giftManifest{}, fmt.Errorf("unsupported gift constructor %T at index %d", class, index) + } +} + +func fetchDocument(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, source *documentSource, maxBytes int64, skipThumbs bool, allowedMissingThumbs map[string]struct{}) (documentManifest, error) { + doc := source.document + if doc.Size <= 0 || doc.Size > maxBytes { + return documentManifest{}, fmt.Errorf("document %d size %d is outside (0, %d]", doc.ID, doc.Size, maxBytes) + } + fileName, stickerAlt := documentNames(doc) + ext := documentExtension(fileName, doc.MimeType) + rel := filepath.Join("documents", fmt.Sprintf("%d%s", doc.ID, ext)) + data, reused, err := existingArtifact(root, rel, doc.Size, maxBytes) + if err != nil { + return documentManifest{}, err + } + if !reused { + fmt.Printf("[network fetch] document=%d resource=document expected_size=%d part_size=%d\n", doc.ID, doc.Size, downloadPartSize(doc.Size)) + data, err = download(ctx, api, dl, doc, "", doc.Size, maxBytes) + if err != nil { + return documentManifest{}, fmt.Errorf("download document %d: %w", doc.ID, err) + } + } + if int64(len(data)) != doc.Size { + return documentManifest{}, fmt.Errorf("document %d size mismatch: got %d want %d", doc.ID, len(data), doc.Size) + } + artifact, err := writeArtifact(root, rel, "document", "", data) + if err != nil { + return documentManifest{}, err + } + purposes := make([]string, 0, len(source.purposes)) + for purpose := range source.purposes { + purposes = append(purposes, purpose) + } + sort.Strings(purposes) + dm := documentManifest{ + ID: doc.ID, + Date: doc.Date, + DCID: doc.DCID, + MimeType: doc.MimeType, + ExpectedSize: doc.Size, + FileName: fileName, + StickerAlt: stickerAlt, + Purposes: purposes, + File: artifact, + } + if ext == ".tgs" || strings.EqualFold(doc.MimeType, "application/x-tgsticker") { + validator := &stargifts.Service{} + if _, err := validator.PrepareAnimation(fmt.Sprintf("%d.tgs", doc.ID), data); err != nil { + dm.ValidationError = err.Error() + } else { + dm.AnimationValidated = true + } + } + if skipThumbs { + return dm, nil + } + thumbs, missingThumbs, err := fetchThumbs(ctx, api, dl, root, doc, maxBytes, allowedMissingThumbs) + if err != nil { + return documentManifest{}, err + } + dm.Thumbs = thumbs + dm.MissingThumbs = missingThumbs + return dm, nil +} + +func fetchThumbs(ctx context.Context, api *tg.Client, dl *downloader.Downloader, root string, doc *tg.Document, maxBytes int64, allowedMissingThumbs map[string]struct{}) ([]fileArtifact, []missingThumb, error) { + seen := make(map[string]struct{}) + artifacts := make([]fileArtifact, 0, len(doc.Thumbs)+len(doc.VideoThumbs)) + missing := make([]missingThumb, 0) + for _, class := range doc.Thumbs { + thumbType := class.GetType() + if thumbType == "" { + continue + } + key := "photo:" + thumbType + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + var data []byte + var err error + var expectedSize int64 + downloadAttempted := false + switch value := class.(type) { + case *tg.PhotoCachedSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoStrippedSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoPathSize: + data = append([]byte(nil), value.Bytes...) + case *tg.PhotoSize: + expectedSize = int64(value.Size) + if expectedSize <= 0 || expectedSize > maxBytes { + return nil, nil, fmt.Errorf("document %d photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + var reused bool + data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes) + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=photo-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize)) + data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes) + } + case *tg.PhotoSizeProgressive: + if len(value.Sizes) == 0 { + return nil, nil, fmt.Errorf("document %d progressive photo thumb %q has no sizes", doc.ID, thumbType) + } + expectedSize = int64(value.Sizes[len(value.Sizes)-1]) + if expectedSize <= 0 || expectedSize > maxBytes { + return nil, nil, fmt.Errorf("document %d progressive photo thumb %q size %d is outside (0, %d]", doc.ID, thumbType, expectedSize, maxBytes) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + var reused bool + data, reused, err = existingArtifact(root, rel, expectedSize, maxBytes) + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=photo-thumb-progressive type=%s expected_size=%d part_size=%d\n", doc.ID, thumbType, expectedSize, downloadPartSize(expectedSize)) + data, err = download(ctx, api, dl, doc, thumbType, expectedSize, maxBytes) + } + default: + continue + } + if err != nil { + if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "photo", thumbType) { + missing = append(missing, missingThumb{Kind: "photo", Type: thumbType, ExpectedSize: expectedSize, Error: err.Error()}) + fmt.Printf("[missing thumb] document=%d kind=photo type=%s expected_size=%d error=%v\n", doc.ID, thumbType, expectedSize, err) + continue + } + return nil, nil, fmt.Errorf("download document %d photo thumb %q: %w", doc.ID, thumbType, err) + } + if len(data) == 0 { + continue + } + if expectedSize > 0 && int64(len(data)) != expectedSize { + return nil, nil, fmt.Errorf("document %d photo thumb %q size mismatch: got %d want %d", doc.ID, thumbType, len(data), expectedSize) + } + rel := filepath.Join("thumbs", fmt.Sprintf("%d-photo-%s.bin", doc.ID, safePart(thumbType))) + artifact, err := writeArtifact(root, rel, "photo", thumbType, data) + if err != nil { + return nil, nil, err + } + artifacts = append(artifacts, artifact) + } + for _, class := range doc.VideoThumbs { + value, ok := class.(*tg.VideoSize) + if !ok || value.Type == "" { + continue + } + key := "video:" + value.Type + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rel := filepath.Join("thumbs", fmt.Sprintf("%d-video-%s.bin", doc.ID, safePart(value.Type))) + data, reused, err := existingArtifact(root, rel, int64(value.Size), maxBytes) + downloadAttempted := false + if err == nil && !reused { + downloadAttempted = true + fmt.Printf("[network fetch] document=%d resource=video-thumb type=%s expected_size=%d part_size=%d\n", doc.ID, value.Type, value.Size, downloadPartSize(int64(value.Size))) + data, err = download(ctx, api, dl, doc, value.Type, int64(value.Size), maxBytes) + } + if err != nil { + if downloadAttempted && missingThumbAllowed(allowedMissingThumbs, doc.ID, "video", value.Type) { + missing = append(missing, missingThumb{Kind: "video", Type: value.Type, ExpectedSize: int64(value.Size), Error: err.Error()}) + fmt.Printf("[missing thumb] document=%d kind=video type=%s expected_size=%d error=%v\n", doc.ID, value.Type, value.Size, err) + continue + } + return nil, nil, fmt.Errorf("download document %d video thumb %q: %w", doc.ID, value.Type, err) + } + artifact, err := writeArtifact(root, rel, "video", value.Type, data) + if err != nil { + return nil, nil, err + } + artifacts = append(artifacts, artifact) + } + sort.Slice(artifacts, func(i, j int) bool { + if artifacts[i].Kind != artifacts[j].Kind { + return artifacts[i].Kind < artifacts[j].Kind + } + return artifacts[i].Type < artifacts[j].Type + }) + sort.Slice(missing, func(i, j int) bool { + if missing[i].Kind != missing[j].Kind { + return missing[i].Kind < missing[j].Kind + } + return missing[i].Type < missing[j].Type + }) + return artifacts, missing, nil +} + +func download(ctx context.Context, api *tg.Client, dl *downloader.Downloader, doc *tg.Document, thumbType string, expectedSize, maxBytes int64) ([]byte, error) { + location := &tg.InputDocumentFileLocation{ + ID: doc.ID, + AccessHash: doc.AccessHash, + FileReference: doc.FileReference, + ThumbSize: thumbType, + } + partSize := downloadPartSize(expectedSize) + if expectedSize > 0 && expectedSize < int64(partSize) { + // TDesktop and DrKLO issue ordinary non-precise upload.getFile requests + // for regular file chunks. A known-size resource that fits in one valid + // chunk needs neither gotd's precise mode nor an EOF probe. + result, err := api.UploadGetFile(ctx, &tg.UploadGetFileRequest{ + Location: location, + Offset: 0, + Limit: partSize, + }) + if err != nil { + return nil, err + } + file, ok := result.(*tg.UploadFile) + if !ok { + return nil, fmt.Errorf("single-chunk upload.getFile returned %T", result) + } + if int64(len(file.Bytes)) > maxBytes { + return nil, fmt.Errorf("download exceeds %d bytes", maxBytes) + } + return append([]byte(nil), file.Bytes...), nil + } + buffer := &boundedBuffer{max: maxBytes} + if _, err := dl.WithPartSize(partSize).Download(api, location).Stream(ctx, buffer); err != nil { + return nil, err + } + return append([]byte(nil), buffer.Bytes()...), nil +} + +func downloadPartSize(expectedSize int64) int { + const ( + unit = int64(4 << 10) + max = int64(512 << 10) + ) + if expectedSize <= 0 || expectedSize >= max { + return int(max) + } + // Choose a valid 4 KiB-aligned limit strictly larger than the file whenever + // possible, so downloader.Stream recognizes the first short chunk as final + // without an extra EOF probe. + partSize := ((expectedSize + 1 + unit - 1) / unit) * unit + if partSize > max { + partSize = max + } + return int(partSize) +} + +func existingArtifact(root, relative string, expectedSize, maxBytes int64) ([]byte, bool, error) { + data, err := os.ReadFile(filepath.Join(root, relative)) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("read existing artifact %q: %w", relative, err) + } + size := int64(len(data)) + if size <= 0 || size > maxBytes || expectedSize >= 0 && size != expectedSize { + return nil, false, nil + } + return data, true, nil +} + +type boundedBuffer struct { + bytes.Buffer + max int64 +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + remaining := b.max - int64(b.Len()) + if remaining <= 0 { + return 0, fmt.Errorf("download exceeds %d bytes", b.max) + } + if int64(len(p)) > remaining { + written, _ := b.Buffer.Write(p[:remaining]) + return written, fmt.Errorf("download exceeds %d bytes", b.max) + } + return b.Buffer.Write(p) +} + +func hasRenderableStickerAttribute(doc *tg.Document) bool { + for _, attribute := range doc.Attributes { + switch attribute.(type) { + case *tg.DocumentAttributeSticker, *tg.DocumentAttributeCustomEmoji: + return true + } + } + return false +} + +func documentNames(doc *tg.Document) (fileName, alt string) { + for _, attribute := range doc.Attributes { + switch value := attribute.(type) { + case *tg.DocumentAttributeFilename: + fileName = filepath.Base(value.FileName) + case *tg.DocumentAttributeSticker: + alt = value.Alt + case *tg.DocumentAttributeCustomEmoji: + alt = value.Alt + } + } + return fileName, alt +} + +func documentExtension(fileName, mimeType string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + switch ext { + case ".tgs", ".webm", ".mp4", ".webp", ".png", ".jpg", ".jpeg": + return ext + } + switch strings.ToLower(mimeType) { + case "application/x-tgsticker", "application/gzip": + return ".tgs" + case "video/webm": + return ".webm" + case "video/mp4": + return ".mp4" + case "image/webp": + return ".webp" + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + default: + return ".bin" + } +} + +func safePart(value string) string { + var builder strings.Builder + for _, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + builder.WriteRune(r) + } + } + if builder.Len() == 0 { + return "unknown" + } + return builder.String() +} + +func parseAllowedMissingThumbs(raw string) (map[string]struct{}, error) { + allowed := make(map[string]struct{}) + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + parts := strings.Split(entry, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid allow-missing-thumb %q: want document_id:photo|video:type", entry) + } + documentID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || documentID <= 0 { + return nil, fmt.Errorf("invalid allow-missing-thumb document ID %q", parts[0]) + } + kind := parts[1] + if kind != "photo" && kind != "video" { + return nil, fmt.Errorf("invalid allow-missing-thumb kind %q", kind) + } + thumbType := parts[2] + if thumbType == "" || safePart(thumbType) != thumbType { + return nil, fmt.Errorf("invalid allow-missing-thumb type %q", thumbType) + } + allowed[missingThumbKey(documentID, kind, thumbType)] = struct{}{} + } + return allowed, nil +} + +func missingThumbAllowed(allowed map[string]struct{}, documentID int64, kind, thumbType string) bool { + _, ok := allowed[missingThumbKey(documentID, kind, thumbType)] + return ok +} + +func missingThumbKey(documentID int64, kind, thumbType string) string { + return fmt.Sprintf("%d:%s:%s", documentID, kind, thumbType) +} + +func writeArtifact(root, relative, kind, artifactType string, data []byte) (fileArtifact, error) { + path := filepath.Join(root, relative) + if err := writeFileAtomic(path, data); err != nil { + return fileArtifact{}, err + } + sum := sha256.Sum256(data) + return fileArtifact{ + Kind: kind, + Type: artifactType, + Path: filepath.ToSlash(relative), + Size: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +func readTLArtifact(root, relative string, target interface{ Decode(*bin.Buffer) error }) (fileArtifact, error) { + data, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + return fileArtifact{}, err + } + if len(data) == 0 { + return fileArtifact{}, fmt.Errorf("TL artifact %q is empty", relative) + } + buffer := &bin.Buffer{Buf: data} + if err := target.Decode(buffer); err != nil { + return fileArtifact{}, fmt.Errorf("decode TL artifact %q: %w", relative, err) + } + if len(buffer.Buf) != 0 { + return fileArtifact{}, fmt.Errorf("TL artifact %q has %d trailing bytes", relative, len(buffer.Buf)) + } + sum := sha256.Sum256(data) + return fileArtifact{ + Kind: "tl", + Path: filepath.ToSlash(relative), + Size: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +func writeFileAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".giftfetch-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return fmt.Errorf("replace %q: %w (remove existing: %v)", path, err, removeErr) + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + } + return nil +} diff --git a/cmd/giftfetch/main_test.go b/cmd/giftfetch/main_test.go new file mode 100644 index 00000000..93233f80 --- /dev/null +++ b/cmd/giftfetch/main_test.go @@ -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"}, + }, + } +} diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go index dae46000..6ebd08e6 100644 --- a/cmd/telesrv-admin/readstore.go +++ b/cmd/telesrv-admin/readstore.go @@ -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 } diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index c72ab72b..8318ae2e 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -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"` } diff --git a/cmd/telesrv-admin/session_test.go b/cmd/telesrv-admin/session_test.go index 51f88416..40c6a3ef 100644 --- a/cmd/telesrv-admin/session_test.go +++ b/cmd/telesrv-admin/session_test.go @@ -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) + } +} diff --git a/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css b/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css deleted file mode 100644 index 6729d008..00000000 --- a/cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css +++ /dev/null @@ -1 +0,0 @@ -:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#f3f5f7;--panel:#fff;--panel-subtle:#f8fafb;--panel-strong:#eef2f5;--line:#d9e1e8;--line-strong:#c2ccd6;--text:#101828;--muted:#667085;--muted-2:#98a2b3;--brand:#176d61;--brand-2:#245b9d;--good:#167447;--warn:#a15c07;--danger:#b42318;--sidebar:#11161d;--sidebar-soft:#1b222b;--sidebar-line:#2c3541;--focus:#176d6129;--shadow:0 18px 52px #10182824}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);margin:0;font:13px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button,input,textarea{font:inherit}a{color:inherit;text-decoration:none}.shell{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{color:#eef2f6;background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;height:100vh;padding:18px 12px;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:0 8px 24px #176d6142}.brand-mark{color:#fff;background:var(--brand);border:1px solid #fff3;border-radius:8px;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:#aeb8c4;margin-top:3px;font-size:11px;display:block}.sidebar-label{color:#8492a6;text-transform:uppercase;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{color:#8fa0b4;cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;width:100%;min-height:38px;padding:0 10px;font-size:12px;font-weight:800;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-section-chevron{color:#8fa0b4;justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{color:#c6d0dc;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;min-height:38px;padding:0 10px;display:grid}.nav-dot{background:#687789;border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{color:#cbd5df;background:#171d25;border:1px solid #27313c;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;min-height:32px;padding:0 8px;display:grid}.runtime-row strong{color:#fff;font-size:11px}.workspace{min-width:0}.topbar{z-index:20;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#fffffff0;justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.topbar h1{margin:2px 0 0;font-size:20px;line-height:1.2}.topbar-actions,.page-actions,.section-action,.entity-badges,.row-actions,.modal-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.language-switch{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:2px;display:inline-flex}.language-switch button{min-width:42px;min-height:24px;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0 9px;font-weight:800}.language-switch button.active{color:#fff;background:var(--brand)}.language-switch button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.actor-pill{color:#344054;background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.overview-band h2,.page-title-row h2,.section-head h2,.modal h2{margin:0;font-size:18px;line-height:1.25}.overview-metrics,.metric-row{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:7px;min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:#afd8bf}.status-item.warn,.metric.warn{border-color:#e7c77e}.metric.danger{border-color:#efb4ad}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:8px;grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;min-height:94px;padding:14px;display:grid}.launcher:hover{border-color:var(--brand)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:8px;place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{color:#344054;background:var(--panel);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;min-height:38px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{border:1px solid var(--line);background:#fff;border-radius:8px;gap:8px;min-width:0;padding:10px;display:grid}.picker-head{color:#344054;justify-content:space-between;align-items:center;gap:8px;min-height:24px;font-weight:800;display:flex}.selected-entity{color:#0f3f38;background:#eef8f5;border:1px solid #b9dcd3;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;min-height:40px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:#52606d;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:7px;max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;background:#fff;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:#f3f8f6}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:#fff2f0;border:1px solid #efb4ad;border-radius:7px}input,textarea{color:var(--text);border:1px solid var(--line-strong);background:#fff;border-radius:7px;outline:none}input{width:190px;height:34px;padding:0 10px}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{border:1px solid var(--line-strong);background:#fff;border-radius:7px;align-items:center;gap:8px;width:min(380px,100%);height:34px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{color:#1d2939;border:1px solid var(--line-strong);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;justify-content:center;align-items:center;gap:6px;min-height:34px;padding:0 12px;display:inline-flex}.btn:hover:not(:disabled){background:#f7f9fb}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;background:var(--brand);border-color:var(--brand)}.btn.primary:hover:not(:disabled){background:#12594f}.btn.ghost{background:var(--panel-subtle)}.btn.danger{color:var(--danger);background:#fff7f5;border-color:#efb4ad}.btn.danger:hover:not(:disabled){background:#ffeceb}.btn.warn{color:var(--warn);background:#fff8ec;border-color:#e7c77e}.btn.warn:hover:not(:disabled){background:#fff1d6}.btn:disabled,.btn.primary:disabled,.btn.warn:disabled,.btn.danger:disabled{color:var(--muted-2);border-color:var(--line);cursor:not-allowed;background:#f3f5f7}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{border:1px solid var(--line);border-radius:8px;width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:#475467;background:var(--panel-strong);font-weight:800;position:sticky;top:0}.data-table tbody tr:hover{background:#fbfcfd}.data-table tr:last-child td{border-bottom:0}.mono{font-family:SFMono-Regular,Consolas,Liberation Mono,monospace}.truncate{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{color:#4f5b68;white-space:nowrap;background:#f3f6f8;border:1px solid #d7e0e8;border-radius:999px;align-items:center;min-height:22px;padding:1px 8px;display:inline-flex}.badge.good{color:var(--good);background:#eef8f2;border-color:#b9dcc7}.badge.danger{color:var(--danger);background:#fff2f0;border-color:#efb4ad}.badge.warn{color:var(--warn);background:#fff8e7;border-color:#e7c77e}.empty-cell{color:var(--muted);text-align:center}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:#344054;border:1px solid var(--line);background:#fbfcfd;border-radius:8px;margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0;padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:#344054;border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{align-items:center;gap:6px;width:100%;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:#8a251d;background:#fff2f0;border:1px solid #efb4ad;border-radius:8px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{color:#d8e6f0;background:#141a22;border:1px solid #2a3542;border-radius:8px;max-height:520px;margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;place-items:center;display:grid}.gift-metrics .metric{background:linear-gradient(145deg,#fff,#f6f9f9);min-height:68px;padding:12px}.gift-metrics .metric strong{font-size:17px}.gift-file-icon{color:var(--brand);background:#eaf6f3;border:1px solid #c7e3dc;flex:none;place-items:center;display:grid}.gift-format-chips{flex-wrap:wrap;flex:none;justify-content:flex-end;gap:6px;display:flex}.gift-format-chips span{color:#33645d;letter-spacing:.02em;background:#eef8f5;border:1px solid #cfe5df;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:800}.gift-list-summary{color:var(--muted);margin-left:auto;font-size:11px;font-weight:700}.gift-import-modal{width:min(860px,100%)}.gift-import-modal-body{gap:14px}.gift-import-note{color:var(--muted);justify-content:space-between;align-items:center;gap:12px;line-height:1.45;display:flex}.gift-file-picker{min-height:78px;color:var(--text);cursor:pointer;background:#fff;border:1px dashed #b7ccc8;border-radius:10px;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 14px;transition:border-color .16s,background .16s,box-shadow .16s;display:grid;position:relative}.gift-file-picker:hover,.gift-file-picker.has-file{border-color:var(--brand);background:#f8fcfb;box-shadow:0 0 0 2px #176d610d}.gift-file-picker input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.gift-file-icon{border-radius:9px;width:40px;height:40px}.gift-file-copy{gap:2px;min-width:0;display:grid}.gift-field-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;font-weight:800}.gift-file-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.gift-file-copy small{color:var(--muted);font-size:11px;font-weight:500}.gift-file-action{color:var(--brand);background:#f0f8f6;border:1px solid #c7e3dc;border-radius:7px;padding:7px 10px;font-size:11px;font-weight:800}.gift-fields-grid{grid-template-columns:minmax(200px,1.5fr) repeat(3,minmax(120px,1fr));gap:10px;display:grid}.gift-fields-grid label,.gift-reason-field{color:var(--muted);gap:6px;font-size:11px;font-weight:700;display:grid}.gift-fields-grid input,.gift-reason-field input{min-width:0;height:38px;color:var(--text);border:1px solid var(--line);background:#fff;border-radius:7px;padding:0 10px}.gift-fields-grid input:focus,.gift-reason-field input:focus{border-color:#77b6aa;outline:none;box-shadow:0 0 0 3px #176d6114}.gift-switch{color:#344054;cursor:pointer;align-items:center;gap:9px;font-size:12px;font-weight:700;display:inline-flex}.gift-switch input{opacity:0;width:1px;height:1px;position:absolute}.gift-switch-track{background:#c8d0d5;border-radius:999px;align-items:center;width:34px;height:19px;padding:2px;transition:background .16s;display:flex}.gift-switch-track span{background:#fff;border-radius:50%;width:15px;height:15px;transition:transform .16s;box-shadow:0 1px 3px #10182838}.gift-switch input:checked+.gift-switch-track{background:var(--brand)}.gift-switch input:checked+.gift-switch-track span{transform:translate(15px)}.gift-switch input:focus-visible+.gift-switch-track{outline-offset:2px;outline:3px solid #176d6129}.gift-validation{color:#d5fff5;background:#173631;border:1px solid #24564e;border-radius:9px;overflow:hidden}.gift-validation-head{color:#e3fff9;background:#ffffff09;border-bottom:1px solid #ffffff17;align-items:center;gap:9px;padding:10px 12px;display:flex}.gift-validation-head div{gap:2px;display:grid}.gift-validation-head span{color:#99cfc4;font-size:10px}.gift-validation pre{color:#d5fff5;max-height:180px;margin:0;padding:11px 12px;font-size:11px;overflow:auto}.gift-animation-shell{background:radial-gradient(circle,#f9f3ff,#eef8f5);place-items:center;min-height:210px;display:grid;position:relative}.gift-animation{width:200px;height:200px}.gift-animation canvas{width:100%!important;height:100%!important}.gift-play{width:30px;height:30px;color:var(--text);border:1px solid var(--line);background:#ffffffe6;border-radius:50%;place-items:center;display:grid;position:absolute;bottom:8px;right:8px}.gift-table-wrap{background:#fff}.gift-table{min-width:1080px}.gift-table th:first-child{width:74px}.gift-table td{vertical-align:middle}.gift-animation-shell.compact{border:1px solid var(--line);border-radius:9px;width:56px;min-height:56px;overflow:hidden}.gift-animation-shell.compact .gift-animation{width:54px;height:54px}.gift-animation-shell.compact .gift-play{width:20px;height:20px;bottom:3px;right:3px}.gift-row-disabled{opacity:.68}.gift-table-title,.gift-sort-order,.gift-source-size,.gift-convert-price{display:block}.gift-table-title{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.gift-sort-order,.gift-source-size,.gift-convert-price{color:var(--muted);margin-top:3px;font-size:10px}.gift-table-price{color:#755b00}.gift-table-actions{align-items:center;gap:6px;display:flex}.collectible-button{color:#6548a8;background:#f7f3ff;border-color:#ddd2f5}.collectible-button:hover{background:#efe8ff;border-color:#cbbaf0}.collectible-modal{width:min(1180px,100%);max-height:min(92vh,980px)}.collectible-modal .modal-head p{color:var(--muted);margin:4px 0 0;font-size:11px}.collectible-modal-body{background:#f5f7fa;gap:16px;padding:16px 18px 22px;overflow:auto}.collectible-loading{min-height:90px;color:var(--muted);justify-content:center;align-items:center;gap:8px;display:flex}.collectible-empty{color:#66568c;background:linear-gradient(135deg,#fbf9ff,#f2f7ff);border:1px dashed #cfc3e9;border-radius:12px;align-items:center;gap:12px;padding:16px;display:flex}.collectible-empty div,.collectible-definition-head>div:first-child,.collectible-section-head>div:first-child{gap:3px;display:grid}.collectible-empty span,.collectible-definition-head span,.collectible-section-head span{color:var(--muted);font-size:10px;font-weight:500}.collectible-active{background:#fff;border:1px solid #ddd6ee;border-radius:12px;overflow:hidden;box-shadow:0 5px 16px #422e6e0d}.collectible-active-head{background:linear-gradient(100deg,#fbf9ff,#f4f9ff);border-bottom:1px solid #e9e4f3;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.collectible-active-head>div{color:#60458f;align-items:center;gap:9px;display:flex}.collectible-active-head>div>div{gap:2px;display:grid}.collectible-active-head span{color:var(--muted);font-size:10px}.collectible-active-grid{background:var(--line);grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:1px;display:grid}.collectible-active-grid article{background:#fff;align-items:center;gap:9px;min-width:0;padding:9px 11px;display:flex}.collectible-active-grid article>div:last-child{gap:2px;min-width:0;display:grid}.collectible-active-grid article strong{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.collectible-active-grid article span{color:var(--muted);font-size:9px}.collectible-definition{border:1px solid var(--line);background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px #1018280a}.collectible-definition-head{border-bottom:1px solid var(--line);background:linear-gradient(110deg,#f8fbfa,#fbf9ff);justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.collectible-main-fields{border-bottom:1px solid var(--line);background:#fbfcfd;padding:14px 16px}.collectible-section{border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section:last-child{border-bottom:0}.collectible-section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.collectible-section-tools{align-items:center;gap:7px;display:flex}.collectible-rows{gap:7px;display:grid}.collectible-row{background:#fafbfc;border:1px solid #e1e6eb;border-radius:9px;align-items:end;gap:7px;padding:9px 9px 9px 36px;display:grid;position:relative}.collectible-row:hover{background:#fff;border-color:#cbd7dd;box-shadow:0 3px 10px #10182809}.collectible-row.animated{grid-template-columns:minmax(120px,1.2fr) 90px 78px minmax(160px,1.4fr) 48px 30px}.collectible-row.backdrop{grid-template-columns:minmax(110px,1.2fr) 70px 80px 70px repeat(4,52px) 48px 30px}.collectible-row-index{color:#71668c;background:#f0edf7;border-right:1px solid #e0d9ed;border-radius:8px 0 0 8px;place-items:center;width:27px;font-size:10px;font-weight:800;display:grid;position:absolute;top:0;bottom:0;left:0}.collectible-row label{gap:4px;min-width:0;display:grid}.collectible-row label>span{color:var(--muted);text-transform:uppercase;letter-spacing:.025em;font-size:9px;font-weight:800}.collectible-row input:not([type=file]){width:100%;min-width:0;height:32px;color:var(--text);font:inherit;background:#fff;border:1px solid #d5dde3;border-radius:7px;padding:0 8px;font-size:11px}.collectible-row input:focus{border-color:#8d7aba;outline:none;box-shadow:0 0 0 3px #6f5bae14}.collectible-file input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.collectible-file em{color:#625080;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;background:#f7f4fd;border:1px dashed #cfc4e1;border-radius:7px;align-items:center;gap:5px;min-width:0;height:32px;padding:0 8px;font-size:10px;font-style:normal;font-weight:700;display:flex;overflow:hidden}.collectible-inline-preview{color:#8c7cae;background:radial-gradient(circle,#fff,#eee8f8);border:1px solid #ded5ed;border-radius:8px;place-items:center;width:42px;height:42px;display:grid;overflow:hidden}.collectible-animation{width:100%;height:100%;overflow:hidden}.collectible-animation.compact{background:radial-gradient(circle,#fff,#f0ebfa);border:1px solid #e0d9ec;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;display:grid}.collectible-animation canvas{width:100%!important;height:100%!important}.collectible-animation.failed{color:#b42318;background:#fff4f2}.collectible-animation.loading{color:#807397}.collectible-file-error{color:#b42318;grid-column:1/-1;font-size:10px}.collectible-color input{cursor:pointer;height:32px!important;padding:3px!important}.collectible-backdrop-preview{border:1px solid #2a1f472e;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;font-size:11px;font-weight:900;display:grid;box-shadow:inset 0 0 0 1px #fff3}.collectible-row .icon-btn{align-self:center}.collectible-row .icon-btn:disabled{opacity:.28}@media (width<=900px){.gift-fields-grid,.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:repeat(2,minmax(0,1fr))}.collectible-inline-preview,.collectible-backdrop-preview,.collectible-row .icon-btn{place-self:center start}}@media (width<=620px){.gift-import-note{flex-direction:column;align-items:flex-start}.gift-format-chips{justify-content:flex-start}.gift-file-picker{grid-template-columns:40px minmax(0,1fr)}.gift-file-action{display:none}.gift-fields-grid{grid-template-columns:1fr}.gift-list-summary{width:100%;margin-left:0}.collectible-modal-body{padding:10px}.collectible-definition-head,.collectible-section-head{flex-direction:column;align-items:flex-start}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:1fr}.collectible-active-grid{grid-template-columns:1fr 1fr}}.modal-backdrop{z-index:10000;background:#11182785;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);background:#fff;border-radius:8px;padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.command-modal>.modal-head,.command-modal>.modal-actions{flex:none}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:7px;place-items:center;width:30px;height:30px;display:grid}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{grid-auto-rows:max-content;gap:12px;min-height:0;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{border:1px solid var(--line);background:#fff;border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:#a9d8ce}.command-step.done{color:var(--good);border-color:#b9dcc7}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:#4b5563;font-weight:800}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:#344054;align-items:center;gap:7px;font-weight:800;display:flex}.result-box{border:1px solid var(--line);background:#fbfcfd;border-radius:8px;gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:#344054}.modal-actions{border-top:1px solid var(--line);background:#fff;justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{border:1px solid var(--line);width:min(420px,100%);box-shadow:var(--shadow);background:#fff;border-radius:8px;gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-head-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:#d7dde4;border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav-list{grid-template-columns:repeat(4,minmax(0,1fr))}.sidebar-status{display:none}.overview-band,.split-layout,.operation-row,.raw-grid,.message-selector-grid,.message-selector-grid.single{grid-template-columns:1fr}.action-dock{position:static}}@media (width<=760px){.content,.topbar{padding-left:14px;padding-right:14px}.command-grid,.work-strip,.overview-metrics,.metric-row,.summary-grid,.command-steps{grid-template-columns:1fr}.sidebar{gap:12px;padding:14px}.nav-list{grid-template-columns:repeat(2,minmax(0,1fr))}.topbar,.page-title-row,.entity-head{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/dist/assets/index-D9dH2J7N.js b/cmd/telesrv-admin/web/dist/assets/index-D9dH2J7N.js new file mode 100644 index 00000000..c42628b3 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-D9dH2J7N.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,M(x);else{var t=n(l);t!==null&&N(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&N(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,N(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,M(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ne(e):``}function ae(e){switch(e.tag){case 5:return ne(e.type);case 16:return ne(`Lazy`);case 13:return ne(`Suspense`);case 19:return ne(`SuspenseList`);case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return``}}function oe(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case M:return`Suspense`;case N:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case j:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case P:return t=e.displayName||null,t===null?oe(e.type)||`Memo`:t;case F:t=e._payload,e=e._init;try{return oe(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return oe(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ce(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ue(e){var t=le(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function z(e){e._valueTracker||=ue(e)}function de(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=le(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function B(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function V(e,t){var n=t.checked;return R({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function fe(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ce(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function pe(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function me(e,t){pe(e,t);var n=ce(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?ge(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&ge(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function he(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function ge(e,t,n){(t!==`number`||B(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var _e=Array.isArray;function ve(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Ce.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Te(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ee={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},De=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ee).forEach(function(e){De.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ee[t]=Ee[e]})});function Oe(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ee.hasOwnProperty(e)&&Ee[e]?(``+t).trim():t+`px`}function ke(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Oe(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ae=R({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function je(e,t){if(t){if(Ae[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Me(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ne=null;function Pe(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Fe=null,Ie=null,Le=null;function Re(e){if(e=ji(e)){if(typeof Fe!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Ni(t),Fe(e.stateNode,e.type,t))}}function ze(e){Ie?Le?Le.push(e):Le=[e]:Ie=e}function Be(){if(Ie){var e=Ie,t=Le;if(Le=Ie=null,Re(e),t)for(e=0;e>>=0,e===0?32:31-(vt(e)/yt|0)|0}var xt=64,St=4194304;function Ct(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function wt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Ct(a))):r=Ct(s)}else o=n&~i,o===0?a!==0&&(r=Ct(a)):r=Ct(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function At(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function jt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=X),Jn=` `,Yn=!1;function Xn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Zn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Qn=!1;function $n(e,t){switch(e){case`compositionend`:return Zn(t);case`keypress`:return t.which===32?(Yn=!0,Jn):null;case`textInput`:return e=t.data,e===Jn&&Yn?null:e;default:return null}}function er(e,t){if(Qn)return e===`compositionend`||!Gn&&Xn(e,t)?(e=hn(),mn=pn=fn=null,Qn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(){for(var e=window,t=B();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=B(e.document)}return t}function Tr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Er(e){var t=wr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Cr(n.ownerDocument.documentElement,n)){if(r!==null&&Tr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Sr(n,a);var o=Sr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Or=null,kr=null,Ar=null,jr=!1;function Mr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;jr||Or==null||Or!==B(r)||(r=Or,`selectionStart`in r&&Tr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ar&&br(Ar,r)||(Ar=r,r=ii(kr,`onSelect`),0Fi||(e.current=Pi[Fi],Pi[Fi]=null,Fi--)}function Ri(e,t){Fi++,Pi[Fi]=e.current,e.current=t}var zi={},Bi=Ii(zi),Vi=Ii(!1),Hi=zi;function Ui(e,t){var n=e.type.contextTypes;if(!n)return zi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Wi(e){return e=e.childContextTypes,e!=null}function Gi(){Li(Vi),Li(Bi)}function Ki(e,t,n){if(Bi.current!==zi)throw Error(r(168));Ri(Bi,t),Ri(Vi,n)}function qi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,se(e)||`Unknown`,a));return R({},n,i)}function Ji(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||zi,Hi=Bi.current,Ri(Bi,e),Ri(Vi,Vi.current),!0}function Yi(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=qi(e,t,Hi),i.__reactInternalMemoizedMergedChildContext=e,Li(Vi),Li(Bi),Ri(Bi,e)):Li(Vi),Ri(Vi,n)}var Xi=null,Zi=!1,Qi=!1;function $i(e){Xi===null?Xi=[e]:Xi.push(e)}function ea(e){Zi=!0,$i(e)}function ta(){if(!Qi&&Xi!==null){Qi=!0;var e=0,t=Y;try{var n=Xi;for(Y=1;e>=o,i-=o,la=1<<32-_t(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),_a&&da(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),_a&&da(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return _a&&da(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),_a&&da(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===F&&ja(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=ka(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=ka(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case F:return l=i._init,_(e,r,l(i._payload),o)}if(_e(i))return h(e,r,i,o);if(ee(i))return g(e,r,i,o);Aa(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Na=Ma(!0),Pa=Ma(!1),Fa=Ii(null),Ia=null,La=null,Ra=null;function za(){Ra=La=Ia=null}function Ba(e){var t=Fa.current;Li(Fa),e._currentValue=t}function Va(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Ha(e,t){Ia=e,Ra=La=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ms=!0),e.firstContext=null)}function Ua(e){var t=e._currentValue;if(Ra!==e)if(e={context:e,memoizedValue:t,next:null},La===null){if(Ia===null)throw Error(r(308));La=e,Ia.dependencies={lanes:0,firstContext:e}}else La=La.next=e;return t}var Wa=null;function Ga(e){Wa===null?Wa=[e]:Wa.push(e)}function Ka(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Ga(t)):(n.next=i.next,i.next=n),t.interleaved=n,qa(e,r)}function qa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Ja=!1;function Ya(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Xa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Za(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Qa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,qa(e,n)}return i=r.interleaved,i===null?(t.next=t,Ga(r)):(t.next=i.next,i.next=t),r.interleaved=t,qa(e,n)}function $a(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}function eo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function to(e,t,n,r){var i=e.updateQueue;Ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=R({},d,f);break a;case 2:Ja=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function no(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=vo.transition;vo.transition={};try{e(!1),t()}finally{Y=n,vo.transition=r}}function as(){return Mo().memoizedState}function os(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},cs(e))ls(t,n);else if(n=Ka(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),us(n,t,r)}}function ss(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(cs(e))ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Z(s,o)){var c=t.interleaved;c===null?(i.next=i,Ga(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ka(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),us(n,t,r))}}function cs(e){var t=e.alternate;return e===bo||t!==null&&t===bo}function ls(e,t){wo=Co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}var ds={readContext:Ua,useCallback:Do,useContext:Do,useEffect:Do,useImperativeHandle:Do,useInsertionEffect:Do,useLayoutEffect:Do,useMemo:Do,useReducer:Do,useRef:Do,useState:Do,useDebugValue:Do,useDeferredValue:Do,useTransition:Do,useMutableSource:Do,useSyncExternalStore:Do,useId:Do,unstable_isNewReconciler:!1},fs={readContext:Ua,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:Ua,useEffect:Jo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Ko(4194308,4,Qo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ko(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ko(4,2,e,t)},useMemo:function(e,t){var n=jo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=jo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=os.bind(null,bo,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:Uo,useDebugValue:es,useDeferredValue:function(e){return jo().memoizedState=e},useTransition:function(){var e=Uo(!1),t=e[0];return e=is.bind(null,e[1]),jo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=bo,a=jo();if(_a){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));yo&30||Ro(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Jo(Bo.bind(null,i,o,e),[e]),i.flags|=2048,Wo(9,zo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=jo(),t=Vc.identifierPrefix;if(_a){var n=ua,r=la;n=(r&~(1<<32-_t(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=To++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[wi]=t,e[Ti]=i,nc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Me(n,i),n){case`dialog`:Zr(`cancel`,e),Zr(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:Zr(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}else{if(!i)if(e=mo(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ac(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!_a)return oc(t),null}else 2*st()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(oc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=st(),t.sibling=null,n=po.current,Ri(po,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(oc(t),t.subtreeFlags&6&&(t.flags|=8192)):oc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function cc(e,t){switch(ma(t),t.tag){case 1:return Wi(t.type)&&Gi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return lo(),Li(Vi),Li(Bi),go(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return fo(t),null;case 13:if(Li(po),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Ea()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Li(po),null;case 4:return lo(),null;case 10:return Ba(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var lc=!1,uc=!1,dc=typeof WeakSet==`function`?WeakSet:Set,Q=null;function fc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function pc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var mc=!1;function hc(e,t){if(fi=an,e=wr(),Tr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(pi={focusedElem:e,selectionRange:n},an=!1,Q=t;Q!==null;)if(t=Q,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Q=e;else for(;Q!==null;){t=Q;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:hs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Q=e;break}Q=t.return}return h=mc,mc=!1,h}function gc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&pc(t,n,a)}i=i.next}while(i!==r)}}function _c(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function yc(e){var t=e.alternate;t!==null&&(e.alternate=null,yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[wi],delete t[Ti],delete t[Di],delete t[Oi],delete t[ki])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bc(e){return e.tag===5||e.tag===3||e.tag===4}function xc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||bc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=di));else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}var wc=null,Tc=!1;function Ec(e,t,n){for(n=n.child;n!==null;)Dc(e,t,n),n=n.sibling}function Dc(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount==`function`)try{ht.onCommitFiberUnmount(mt,n)}catch{}switch(n.tag){case 5:uc||fc(n,t);case 6:var r=wc,i=Tc;wc=null,Ec(e,t,n),wc=r,Tc=i,wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):wc.removeChild(n.stateNode));break;case 18:wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?bi(e.parentNode,n):e.nodeType===1&&bi(e,n),nn(e)):bi(wc,n.stateNode));break;case 4:r=wc,i=Tc,wc=n.stateNode.containerInfo,Tc=!0,Ec(e,t,n),wc=r,Tc=i;break;case 0:case 11:case 14:case 15:if(!uc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&pc(n,t,o),i=i.next}while(i!==r)}Ec(e,t,n);break;case 1:if(!uc&&(fc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Ec(e,t,n);break;case 21:Ec(e,t,n);break;case 22:n.mode&1?(uc=(r=uc)||n.memoizedState!==null,Ec(e,t,n),uc=r):Ec(e,t,n);break;default:Ec(e,t,n)}}function Oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new dc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function kc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=st()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Lc(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,$&6)throw Error(r(331));var a=$;for($|=4,Q=e.current;Q!==null;){var o=Q,s=o.child;if(Q.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lst()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=St,St<<=1,!(St&130023424)&&(St=4194304)):t=1);var n=fl();e=qa(e,t),e!==null&&(At(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Vi.current)Ms=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ms=!1,tc(e,t,n);Ms=!!(e.flags&131072)}else Ms=!1,_a&&t.flags&1048576&&fa(t,aa,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;$s(e,t),e=t.pendingProps;var a=Ui(t,Bi.current);Ha(t,n),a=ko(null,t,i,e,a,n);var o=Ao();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Wi(i)?(o=!0,Ji(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ya(t),a.updater=_s,t.stateNode=a,a._reactInternals=t,xs(t,i,e,n),t=Vs(null,t,i,!0,o,n)):(t.tag=0,_a&&o&&pa(t),Ns(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch($s(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=hs(i,e),a){case 0:t=zs(null,t,i,e,n);break a;case 1:t=Bs(null,t,i,e,n);break a;case 11:t=Ps(null,t,i,e,n);break a;case 14:t=Fs(null,t,i,hs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),zs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Bs(e,t,i,a,n);case 3:a:{if(Hs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Xa(e,t),to(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Ss(Error(r(423)),t),t=Us(e,t,i,n,a);break a}else if(i!==a){a=Ss(Error(r(424)),t),t=Us(e,t,i,n,a);break a}else for(ga=xi(t.stateNode.containerInfo.firstChild),ha=t,_a=!0,va=null,n=Pa(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Ea(),i===a){t=ec(e,t,n);break a}Ns(e,t,i,n)}t=t.child}return t;case 5:return uo(t),e===null&&Sa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,mi(i,a)?s=null:o!==null&&mi(i,o)&&(t.flags|=32),Rs(e,t),Ns(e,t,s,n),t.child;case 6:return e===null&&Sa(t),null;case 13:return Ks(e,t,n);case 4:return co(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Na(t,null,i,n):Ns(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Ps(e,t,i,a,n);case 7:return Ns(e,t,t.pendingProps,n),t.child;case 8:return Ns(e,t,t.pendingProps.children,n),t.child;case 12:return Ns(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,Ri(Fa,i._currentValue),i._currentValue=s,o!==null)if(Z(o.value,s)){if(o.children===a.children&&!Vi.current){t=ec(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=Za(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Va(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Va(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Ns(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Ha(t,n),a=Ua(a),i=i(a),t.flags|=1,Ns(e,t,i,n),t.child;case 14:return i=t.type,a=hs(i,t.pendingProps),a=hs(i.type,a),Fs(e,t,i,a,n);case 15:return Is(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),$s(e,t),t.tag=1,Wi(i)?(e=!0,Ji(t)):e=!1,Ha(t,n),ys(t,i,a),xs(t,i,a,n),Vs(null,t,i,!0,e,n);case 19:return Qs(e,t,n);case 22:return Ls(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return rt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===j)return 11;if(e===P)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case M:return e=Kl(13,n,t,a),e.elementType=M,e.lanes=o,e;case N:return e=Kl(19,n,t,a),e.elementType=N,e.lanes=o,e;case I:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case j:s=11;break a;case P:s=14;break a;case F:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=I,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=kt(0),this.expirationTimes=kt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=kt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ya(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}};async function y(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{credentials:`same-origin`,headers:n?t.headers:{"Content-Type":`application/json`,...t.headers??{}},...t}),i=await r.text(),a=i?JSON.parse(i):null;if(!r.ok){let e=a?.error||a?.Error||a?.message||r.statusText;throw new v(r.status,e)}return a}function b(e){return e instanceof Error?e.message:String(e)}var x={session:()=>y(`/api/session`),login:e=>y(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})}),logout:()=>y(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>y(`/api/accounts?${e.toString()}`),account:e=>y(`/api/accounts/${e}`),channels:e=>y(`/api/channels?${e.toString()}`),channel:e=>y(`/api/channels/${e}`),messages:e=>y(`/api/messages?${e.toString()}`),message:(e,t)=>y(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>y(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>y(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),gifts:()=>y(`/api/gifts`),officialGifts:()=>y(`/api/official-gifts`),officialGiftAnimation:e=>y(`/api/official-gifts/${encodeURIComponent(e)}/animation`),giftAnimation:e=>y(`/api/gifts/${encodeURIComponent(e)}/animation`),giftCollectibles:e=>y(`/api/gifts/${encodeURIComponent(e)}/collectibles`),giftCollectibleAnimation:(e,t,n)=>y(`/api/gifts/${encodeURIComponent(e)}/collectibles/${t}/${encodeURIComponent(n)}/animation`),importGift:e=>y(`/api/actions/import-gift`,{method:`POST`,body:e}),importOfficialGift:e=>y(`/api/actions/import-official-gift`,{method:`POST`,body:JSON.stringify(e)}),publishGiftCollectibles:(e,t)=>y(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(e)}`,{method:`POST`,body:t}),action:(e,t)=>y(e,{method:`POST`,body:JSON.stringify(t)})},S=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),C=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),w={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},T=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...w,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:C(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),E=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(T,{ref:i,iconNode:t,className:C(`lucide-${S(e)}`,n),...r}));return n.displayName=`${e}`,n},D=E(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),O=E(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),k=E(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),A=E(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),j=E(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),M=E(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),N=E(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),P=E(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),F=E(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),I=E(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),L=E(`Clock3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16.5 12`,key:`1aq6pp`}]]),ee=E(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),R=E(`FileJson2`,[[`path`,{d:`M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4`,key:`1pf5j1`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M4 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`fq0c9t`}],[`path`,{d:`M8 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`4gibmv`}]]),te=E(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),ne=E(`Gem`,[[`path`,{d:`M6 3h12l4 6-10 13L2 9Z`,key:`1pcd5k`}],[`path`,{d:`M11 3 8 9l4 13 4-13-3-6`,key:`1fcu3u`}],[`path`,{d:`M2 9h20`,key:`16fsjt`}]]),re=E(`Gift`,[[`rect`,{x:`3`,y:`8`,width:`18`,height:`4`,rx:`1`,key:`bkv52`}],[`path`,{d:`M12 8v13`,key:`1c76mn`}],[`path`,{d:`M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7`,key:`6wjy6b`}],[`path`,{d:`M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5`,key:`1ihvrl`}]]),ie=E(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),ae=E(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),oe=E(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),se=E(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),ce=E(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),le=E(`Pause`,[[`rect`,{x:`14`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`zuxfzm`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`1okwgv`}]]),ue=E(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),z=E(`Plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),de=E(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),B=E(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),V=E(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),fe=E(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),pe=E(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),me=E(`Star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),he=E(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),ge=E(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),_e=E(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),ve=E(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),ye=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),H=o(((e,t)=>{t.exports=ye()}))(),U=`telesrv.admin.lang`,be={en:{"app.adminConsole":`Admin Console`,"app.localAccess":`Local access`,"app.title":`telesrv admin`,"common.actions":`Actions`,"common.admins":`Admins`,"common.backToList":`Back to list`,"common.channel":`Channel`,"common.channelOrGroup":`Channel / Group`,"common.clear":`Clear`,"common.close":`Close`,"common.count":`Count`,"common.deleted":`Deleted`,"common.detail":`Details`,"common.device":`Device`,"common.disabled":`Disabled`,"common.enabled":`Enabled`,"common.fromPeer":`From Peer`,"common.group":`Group`,"common.id":`ID`,"common.limit":`Limit`,"common.loading":`Loading`,"common.member":`Member`,"common.members":`Members`,"common.messageId":`Message ID`,"common.name":`Name`,"common.no":`No`,"common.noResults":`No results`,"common.none":`None`,"common.normal":`Normal`,"common.operations":`Operations`,"common.owner":`Owner`,"common.platform":`Platform`,"common.refresh":`Refresh`,"common.search":`Search`,"common.sender":`Sender`,"common.status":`Status`,"common.survived":`Live`,"common.time":`Time`,"common.type":`Type`,"common.updatedAt":`Updated`,"common.username":`Username`,"common.valid":`Valid`,"common.verified":`Verified`,"common.views":`Views`,"common.yes":`Yes`,"route.accounts":`Accounts`,"route.accountsSubtitle":`Console / Accounts`,"route.channels":`Supergroups and Channels`,"route.channelsSubtitle":`Console / Channels`,"route.dashboard":`Operations Console`,"route.dashboardSubtitle":`Console / Overview`,"route.messages":`Message Audit`,"route.messagesSubtitle":`Console / Messages`,"route.gifts":`Star Gifts`,"route.giftsSubtitle":`Console / Star Gifts`,"layout.navigation":`Navigation`,"layout.primaryNav":`Primary navigation`,"layout.dashboard":`Overview`,"layout.accounts":`Accounts`,"layout.channels":`Supergroups / Channels`,"layout.messages":`Messages`,"layout.gifts":`Star Gifts`,"layout.privateMessages":`Private`,"layout.groupMessages":`Groups`,"layout.runtime":`Runtime`,"layout.adminBackend":`Admin backend`,"layout.ready":`Ready`,"layout.pgRead":`PG read`,"layout.readOnly":`Read-only`,"layout.writeOps":`Write operations`,"layout.dryRun":`Dry-run`,"layout.actor":`Actor: {actor}`,"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`,"login.submit":`Log in`,"login.submitting":`Logging in`,"dashboard.eyebrow":`Runtime Overview`,"dashboard.title":`Console Overview`,"dashboard.readPath":`Read path`,"dashboard.readPathValue":`PG read-only`,"dashboard.writePath":`Write path`,"dashboard.executionPolicy":`Execution policy`,"dashboard.dryRunFirst":`Dry-run first`,"dashboard.accountsText":`Account status, premium, verification, sessions.`,"dashboard.channelsText":`Public entities, member counts, verification state.`,"dashboard.messagesText":`Message boxes, updates, outbox state.`,"dashboard.strip.dryRun":`All dangerous actions start with dry-run`,"dashboard.strip.token":`Browser never stores internal tokens`,"dashboard.strip.pagination":`Lists use cursor pagination`,"dashboard.strip.snapshot":`Detail pages retain raw state snapshots`,"account.pageTitle":`Accounts`,"account.queryResults":`Search results`,"account.recentActive":`Recently active accounts`,"account.currentPage":`Accounts on page`,"account.onlineDevices":`Online device records`,"account.premium":`Premium`,"account.frozen":`Frozen`,"account.searchPlaceholder":`User ID / phone / username`,"account.userID":`User ID`,"account.phone":`Phone`,"account.lastActive":`Last active`,"account.notVerified":`Not verified`,"account.notPremium":`Not premium`,"account.premiumUntil":`Premium expires`,"account.starsBalance":`Stars balance`,"account.startingGrantApplied":`initial grant applied`,"account.startingGrantPending":`initial grant pending`,"account.activeSessions":`Authorized devices`,"account.accountFlags":`Account flags`,"account.restriction":`Restriction`,"account.restricted":`Restricted`,"account.createdAt":`Created`,"account.detailTitle":`Account #{id}`,"account.profile":`Account Profile`,"account.loadingDetail":`Loading account detail`,"account.waitingData":`Waiting for data`,"account.noUsername":`No username`,"account.noPhone":`No phone`,"account.accountFrozen":`Account frozen`,"account.accountActive":`Account active`,"account.authorizationsTitle":`Authorized Devices`,"account.authorizationsCount":`{count} authorizations`,"account.recentAdminOps":`Recent Admin Actions`,"account.recent30Audit":`Last 30 audit rows`,"account.actionDock":`Account Actions`,"account.freezeAccount":`Freeze account`,"account.updateFreeze":`Update freeze`,"account.unfreezeAccount":`Unfreeze account`,"account.freezeSince":`Frozen since`,"account.freezeUntil":`Appeal deadline`,"account.freezeUntilAria":`Freeze appeal deadline`,"account.freezeAppealURL":`Appeal URL`,"account.freezeAppealURLAria":`Freeze appeal URL`,"account.premiumMonths":`Premium duration (months)`,"account.premiumMonthsAria":`Set premium duration in months`,"account.setPremium":`Set premium`,"account.clearPremium":`Clear premium`,"account.starsAmount":`Stars to grant`,"account.starsAmountAria":`Set Stars amount to grant`,"account.grantStars":`Grant Stars`,"account.setVerified":`Set verified`,"account.clearVerified":`Clear verified`,"channel.pageTitle":`Supergroups and Channels`,"channel.recentUpdated":`Recently updated`,"channel.currentPage":`Entities on page`,"channel.megagroups":`Supergroups`,"channel.broadcasts":`Channels`,"channel.verifiedCount":`Verified`,"channel.searchPlaceholder":`Channel ID / username / title`,"channel.channelID":`Channel ID`,"channel.kind":`Kind`,"channel.title":`Title`,"channel.pts":`PTS`,"channel.detailProfile":`Channel Profile`,"channel.loadingDetail":`Loading channel detail`,"channel.creator":`Creator {id}`,"channel.governance":`Moderation`,"channel.governanceValue":`Banned {banned} / Kicked {kicked}`,"channel.flags":`Channel flags`,"channel.rawRow":`Channel Raw Row`,"channel.rawRowText":`Database read-only snapshot`,"channel.actionDock":`Channel Actions`,"channel.setVerified":`Set verified`,"channel.clearVerified":`Clear verified`,"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`,"messages.privateTitle":`Private Messages`,"messages.privateEyebrow":`Private message boxes`,"messages.groupTitle":`Group Messages`,"messages.groupEyebrow":`Supergroup / channel messages`,"messages.selectPrivatePeers":`Search and select the owner user and peer user first`,"messages.selectChannel":`Search and select a supergroup or channel first`,"messages.ownerUser":`Owner user`,"messages.peerUser":`Peer user`,"messages.beforeDatePlaceholder":`before_date cursor`,"messages.beforeIDPlaceholder":`before_msg_id cursor`,"messages.limitPlaceholder":`limit <= 100`,"messages.searchMessages":`Search messages`,"messages.nextPage":`Next page`,"messages.currentPage":`Messages on page`,"messages.deleted":`Deleted`,"messages.outgoing":`Outgoing`,"messages.incoming":`Incoming`,"messages.ownerPeer":`Owner / Peer`,"messages.deleteSelected":`Delete selected messages`,"messages.idsPlaceholder":`Message IDs, comma separated`,"messages.revoke":`Revoke for both sides`,"messages.previewDelete":`Dry-run delete`,"messages.clearHistory":`Clear private history`,"messages.maxIDPlaceholder":`max_id cutoff`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Clear only this side`,"messages.previewClearHistory":`Dry-run clear history`,"messages.direction":`Direction`,"messages.body":`Body`,"messages.privateDetailTitle":`Message #{id}`,"messages.detailEyebrow":`Message Detail`,"messages.backPrivate":`Back to private messages`,"messages.backGroup":`Back to group messages`,"messages.ownerPeerTitle":`Owner {owner} · Peer {peer}`,"messages.senderSubtitle":`Sender {sender} · {date}`,"messages.boxID":`Message box ID`,"messages.privateMessageID":`Private message ID`,"messages.messageSender":`Message sender`,"messages.messageBox":`Message Box`,"messages.dialogRow":`Dialog Row`,"messages.privateRow":`Private Message Row`,"messages.channelMessageRow":`Channel Message Row`,"messages.channelRow":`Channel Row`,"messages.userUpdateEvents":`Update Events`,"messages.channelUpdateEvents":`Channel Update Events`,"messages.eventJson":`Event JSON`,"messages.dispatchOutbox":`Dispatch Queue`,"messages.messageBoxesSnapshot":`message_boxes read-only snapshot`,"messages.dialogSnapshot":`dialogs read-only snapshot`,"messages.privateSnapshot":`private_messages read-only snapshot`,"messages.channelMessagesSnapshot":`channel_messages read-only snapshot`,"messages.channelSnapshot":`channels read-only snapshot`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`online/offline dispatch_outbox`,"messages.attempts":`Attempts`,"messages.deleteThis":`Delete this message`,"messages.groupDetailTitle":`Group Message #{id}`,"messages.channelGroupTitle":`Channel / Group {id}`,"messages.mediaCount":`With media`,"messages.channelPosts":`Channel posts`,"messages.channelGroup":`Channel / Group`,"messages.pinned":`Pinned`,"messages.channelPost":`Channel post`,"gifts.pageTitle":`Star Gift Catalog`,"gifts.eyebrow":`Catalog, immutable revisions and animation assets`,"gifts.total":`Catalog entries`,"gifts.enabled":`Enabled`,"gifts.received":`Received gifts`,"gifts.formats":`Accepted formats`,"gifts.add":`Add gift`,"gifts.searchPlaceholder":`Search gift ID, title or format`,"gifts.listSummary":`Showing {shown} of {total}`,"gifts.idRevision":`ID / Revision`,"gifts.price":`Price / Conversion`,"gifts.importTitle":`Import a Star Gift`,"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`,"gifts.chooseFile":`Choose file`,"gifts.changeFile":`Change file`,"gifts.title":`Display title`,"gifts.titlePlaceholder":`e.g. Celebration Star`,"gifts.stars":`Price in Stars`,"gifts.convertStars":`Conversion Stars`,"gifts.sortOrder":`Sort order`,"gifts.reason":`Audit reason`,"gifts.reasonPlaceholder":`Briefly describe why this gift is being imported`,"gifts.enableAfterImport":`Enable after import`,"gifts.validate":`Dry-run validation`,"gifts.confirmImport":`Confirm import`,"gifts.stepDetails":`File and details`,"gifts.stepValidate":`Dry-run validation`,"gifts.stepImport":`Confirm import`,"gifts.fileRequired":`Choose a TGS or Lottie file first`,"gifts.source":`Source`,"gifts.replace":`New revision`,"gifts.disable":`Disable`,"gifts.enable":`Enable`,"gifts.empty":`No Star Gifts have been imported.`,"gifts.emptyHint":`Import the first animation above to build the gift catalog.`,"gifts.validationReady":`Validation passed`,"gifts.validationHint":`Review the normalized metadata, then confirm the import.`,"gifts.confirmState":`Apply the validated state change to gift #{id}?`,"collectibles.manage":`Attribute pool`,"collectibles.title":`Collectible pool · Gift #{id}`,"collectibles.eyebrow":`Unique gift attributes`,"collectibles.activeRevision":`Published revision {revision}`,"collectibles.published":`Published`,"collectibles.noPool":`No collectible pool published`,"collectibles.noPoolHint":`Publish models, patterns and backdrops to enable upgrades.`,"collectibles.publishNew":`Publish a new immutable revision`,"collectibles.immutableHint":`Dry-run checks every file and rarity total before the revision becomes active.`,"collectibles.upgradeStars":`Upgrade price in Stars`,"collectibles.supply":`Unique supply`,"collectibles.slug":`Public slug prefix`,"collectibles.models":`Models`,"collectibles.patterns":`Patterns`,"collectibles.backdrops":`Backdrops`,"collectibles.model":`Model`,"collectibles.pattern":`Pattern`,"collectibles.backdrop":`Backdrop`,"collectibles.rarity":`Rarity ‰`,"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.`,"collectibles.backdropID":`Backdrop ID`,"collectibles.color.center":`Center`,"collectibles.color.edge":`Edge`,"collectibles.color.pattern":`Pattern`,"collectibles.color.text":`Text`,"collectibles.validationReady":`Attribute pool is valid`,"collectibles.validationHint":`Review the normalized assets, then publish this immutable revision.`,"collectibles.publish":`Publish revision`,"messages.msgIDsInvalid":`Message IDs are invalid`,"auth.device":`Device`,"auth.platform":`Platform`,"auth.ip":`IP`,"auth.lastActive":`Last active`,"auth.revokeCurrent":`Revoke current`,"auth.keepCurrent":`Keep current`,"auth.revokeAll":`Revoke all devices`,"picker.userPlaceholder":`Search user_id / phone / username`,"picker.channelPlaceholder":`Search channel_id / username / title`,"picker.verified":`Verified`,"picker.regular":`Regular`,"action.reasonRequired":`Please enter an operation reason`,"action.flow":`Action Flow`,"action.close":`Close`,"action.stepReason":`Enter reason`,"action.stepDryRun":`Dry-run check`,"action.stepConfirm":`Confirm execution`,"action.reason":`Operation reason`,"action.reasonPlaceholder":`Describe why this operation is being performed`,"action.requestPreview":`Request preview`,"action.result":`Action result`,"action.commandID":`Command ID`,"action.status":`Status`,"action.dryRun":`Dry-run`,"action.runAgain":`Run dry-run again`,"action.runDry":`Run dry-run first`,"action.confirm":`Confirm execution`,"audit.id":`ID`,"audit.commandID":`Command ID`,"audit.action":`Action`,"audit.actor":`Actor`,"audit.status":`Status`,"audit.dryRun":`Dry-run`,"audit.reason":`Reason`,"audit.time":`Time`},zh:{"app.adminConsole":`管理控制台`,"app.localAccess":`本地访问`,"app.title":`telesrv 管理后台`,"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":`From Peer`,"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":`管理员密码或 token`,"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":`消息盒、update、outbox 状态。`,"dashboard.strip.dryRun":`所有危险操作先预演`,"dashboard.strip.token":`浏览器不持有内部 token`,"dashboard.strip.pagination":`列表使用游标分页`,"dashboard.strip.snapshot":`详情页保留原始状态快照`,"account.pageTitle":`账号`,"account.queryResults":`查询结果`,"account.recentActive":`最近活跃账号`,"account.currentPage":`当前页账号`,"account.onlineDevices":`在线设备记录`,"account.premium":`会员`,"account.frozen":`冻结`,"account.searchPlaceholder":`用户 ID / 手机号 / 用户名`,"account.userID":`用户 ID`,"account.phone":`手机号`,"account.lastActive":`最近活跃`,"account.notVerified":`未认证`,"account.notPremium":`非会员`,"account.premiumUntil":`会员到期`,"account.starsBalance":`Stars 余额`,"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":`申诉链接`,"account.freezeAppealURLAria":`账号冻结申诉链接`,"account.premiumMonths":`会员时长(月)`,"account.premiumMonthsAria":`设置会员时长,单位月`,"account.setPremium":`设置会员`,"account.clearPremium":`取消会员`,"account.starsAmount":`赠送 Stars 数量`,"account.starsAmountAria":`设置要赠送的 Stars 数量`,"account.grantStars":`赠送 Stars`,"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":`分发队列`,"messages.messageBoxesSnapshot":`message_boxes 只读快照`,"messages.dialogSnapshot":`dialogs 只读快照`,"messages.privateSnapshot":`private_messages 只读快照`,"messages.channelMessagesSnapshot":`channel_messages 只读快照`,"messages.channelSnapshot":`channels 只读快照`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable 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 的已校验快照中选择;完整 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,导入前会先进行校验`,"gifts.chooseFile":`选择文件`,"gifts.changeFile":`更换文件`,"gifts.title":`显示标题`,"gifts.titlePlaceholder":`例如:庆典星星`,"gifts.stars":`售价 Stars`,"gifts.convertStars":`可兑换 Stars`,"gifts.sortOrder":`排序值`,"gifts.reason":`审计原因`,"gifts.reasonPlaceholder":`简要说明本次导入礼物的原因`,"gifts.enableAfterImport":`导入后启用`,"gifts.validate":`Dry-run 校验`,"gifts.confirmImport":`确认导入`,"gifts.stepDetails":`文件与信息`,"gifts.stepValidate":`Dry-run 校验`,"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":`Collectibles 属性池 · 礼物 #{id}`,"collectibles.eyebrow":`唯一礼物属性管理`,"collectibles.activeRevision":`已发布版本 {revision}`,"collectibles.published":`已发布`,"collectibles.noPool":`尚未发布 Collectibles 属性池`,"collectibles.noPoolHint":`发布模型、图案与背景后,客户端即可升级为唯一礼物。`,"collectibles.publishNew":`发布新的不可变版本`,"collectibles.immutableHint":`Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。`,"collectibles.upgradeStars":`升级价格 Stars`,"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 / phone / username`,"picker.channelPlaceholder":`搜索 channel_id / username / title`,"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":`时间`},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":`Время`}},xe=(0,g.createContext)(null);function Se({children:e}){let[t,n]=(0,g.useState)(()=>Ee());(0,g.useEffect)(()=>{try{localStorage.setItem(U,t)}catch{}let e=t===`zh`?`zh-CN`:t===`ru`?`ru`:`en`;document.documentElement.lang=e,document.documentElement.dir=`ltr`,document.documentElement.setAttribute(`translate`,`no`),document.body.classList.add(`notranslate`),document.title=Te(t,`app.title`)},[t]);let r=(0,g.useMemo)(()=>({lang:t,setLang:n,t:(e,n)=>Te(t,e,n)}),[t]);return(0,H.jsx)(xe.Provider,{value:r,children:e})}function Ce(){let e=(0,g.useContext)(xe);if(!e)throw Error(`useI18n must be used inside I18nProvider`);return e}function we(){let{lang:e,setLang:t,t:n}=Ce();return(0,H.jsx)(`div`,{className:`language-switch`,role:`group`,"aria-label":`Language`,children:[`en`,`zh`,`ru`].map(r=>(0,H.jsx)(`button`,{className:e===r?`active`:``,type:`button`,"aria-pressed":e===r,onClick:()=>t(r),children:n(`language.${r}`)},r))})}function Te(e,t,n){let r=be[e][t]??be.en[t]??t;return n?r.replace(/\{(\w+)\}/g,(e,t)=>String(n[t]??``)):r}function Ee(){try{let e=De(new URLSearchParams(window.location.search).get(`lang`));if(e)return e}catch{}try{let e=De(localStorage.getItem(U));if(e)return e}catch{}let e=navigator.languages?.length?navigator.languages:[navigator.language];for(let t of e){let e=De(t);if(e)return e}return`en`}function De(e){if(!e)return null;let t=e.trim().toLowerCase().replace(`_`,`-`);return t===`zh`||t.startsWith(`zh-`)?`zh`:t===`en`||t.startsWith(`en-`)?`en`:t===`ru`||t.startsWith(`ru-`)?`ru`:null}function Oe(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function ke(e,t){return e.startsWith(`/accounts`)?t(`route.accounts`):e.startsWith(`/channels`)?t(`route.channels`):e.startsWith(`/messages`)?t(`route.messages`):e.startsWith(`/gifts`)?t(`route.gifts`):t(`route.dashboard`)}function Ae(e,t){return e.startsWith(`/accounts`)?t(`route.accountsSubtitle`):e.startsWith(`/channels`)?t(`route.channelsSubtitle`):e.startsWith(`/messages`)?t(`route.messagesSubtitle`):e.startsWith(`/gifts`)?t(`route.giftsSubtitle`):t(`route.dashboardSubtitle`)}function je({href:e,navigate:t,className:n,children:r}){return(0,H.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function Me(){let{t:e}=Ce();return(0,H.jsxs)(`div`,{className:`boot-screen`,children:[(0,H.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:e(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`loader-bar`})]})}function Ne({actor:e,route:t,navigate:n,onLogout:r,children:i}){let{t:a}=Ce(),o=t.path.startsWith(`/messages`),[s,c]=(0,g.useState)(o);(0,g.useEffect)(()=>{o&&c(!0)},[o]);async function l(){await x.logout().catch(()=>void 0),r()}return(0,H.jsxs)(`div`,{className:`shell`,children:[(0,H.jsxs)(`aside`,{className:`sidebar`,children:[(0,H.jsxs)(je,{className:`brand`,href:`/`,navigate:n,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:a(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.navigation`)}),(0,H.jsxs)(`nav`,{className:`nav-list`,"aria-label":a(`layout.primaryNav`),children:[(0,H.jsx)(Pe,{icon:(0,H.jsx)(oe,{size:16}),href:`/`,route:t,navigate:n,children:a(`layout.dashboard`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(_e,{size:16}),href:`/accounts`,route:t,navigate:n,children:a(`layout.accounts`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(fe,{size:16}),href:`/channels`,route:t,navigate:n,children:a(`layout.channels`)}),(0,H.jsx)(Pe,{icon:(0,H.jsx)(re,{size:16}),href:`/gifts`,route:t,navigate:n,children:a(`layout.gifts`)}),(0,H.jsxs)(`div`,{className:`nav-section ${o?`active`:``} ${s?`open`:``}`,children:[(0,H.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":s,onClick:()=>c(e=>!e),children:[(0,H.jsx)(ce,{size:16}),(0,H.jsx)(`span`,{children:a(`layout.messages`)}),(0,H.jsx)(F,{className:`nav-section-chevron`,size:15})]}),s&&(0,H.jsxs)(`div`,{className:`nav-children`,children:[(0,H.jsx)(Pe,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:a(`layout.privateMessages`)}),(0,H.jsx)(Pe,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:a(`layout.groupMessages`)})]})]})]}),(0,H.jsxs)(`div`,{className:`sidebar-status`,children:[(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.runtime`)}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(V,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.adminBackend`)}),(0,H.jsx)(`strong`,{children:a(`layout.ready`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(ee,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.pgRead`)}),(0,H.jsx)(`strong`,{children:a(`layout.readOnly`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(pe,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.writeOps`)}),(0,H.jsx)(`strong`,{children:a(`layout.dryRun`)})]})]})]}),(0,H.jsxs)(`div`,{className:`workspace`,children:[(0,H.jsxs)(`header`,{className:`topbar`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:Ae(t.path,a)}),(0,H.jsx)(`h1`,{children:ke(t.path,a)})]}),(0,H.jsxs)(`div`,{className:`topbar-actions`,children:[(0,H.jsx)(we,{}),(0,H.jsx)(`span`,{className:`actor-pill`,children:a(`layout.actor`,{actor:e})}),(0,H.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:l,title:a(`layout.logout`),children:[(0,H.jsx)(se,{size:16}),` `,a(`layout.logout`)]})]})]}),(0,H.jsx)(`main`,{className:`content`,children:i})]})]})}function Pe({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,H.jsxs)(je,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,H.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,H.jsx)(`span`,{children:i})]})}function Fe(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function Ie(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Le(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function Re(e,t){let n=t??(e=>({"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`})[e]??e);return e.Broadcast&&!e.Megagroup?n(`channel.kind.broadcast`):e.Megagroup&&e.Forum?n(`channel.kind.forum`):e.Megagroup?n(`channel.kind.megagroup`):n(`channel.kind.generic`)}function ze(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Be(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function W(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ve(e,t=`msg ids invalid`){let n=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(n.length===0||n.some(e=>!Number.isFinite(e)||e<=0))throw Error(t);return n}function He({title:e,eyebrow:t,children:n,actions:r}){return(0,H.jsxs)(`div`,{className:`page-frame`,children:[(0,H.jsxs)(`div`,{className:`page-title-row`,children:[(0,H.jsxs)(`div`,{children:[t&&(0,H.jsx)(`div`,{className:`eyebrow`,children:t}),(0,H.jsx)(`h2`,{children:e})]}),r&&(0,H.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function Ue({children:e}){return(0,H.jsx)(`div`,{className:`query-panel`,children:e})}function We({main:e,side:t}){return(0,H.jsxs)(`div`,{className:`split-layout`,children:[(0,H.jsx)(`div`,{className:`split-main`,children:e}),(0,H.jsx)(`aside`,{className:`split-side`,children:t})]})}function Ge({title:e,text:t,action:n}){return(0,H.jsxs)(`div`,{className:`section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`h2`,{children:e}),t&&(0,H.jsx)(`p`,{children:t})]}),n&&(0,H.jsx)(`div`,{className:`section-action`,children:n})]})}function Ke({children:e}){return(0,H.jsxs)(`div`,{className:`alert`,children:[(0,H.jsx)(O,{size:16}),` `,(0,H.jsx)(`span`,{children:e})]})}function G({children:e,tone:t=`neutral`}){return(0,H.jsx)(`span`,{className:`badge ${t}`,children:e})}function K({label:e,value:t,tone:n}){return(0,H.jsxs)(`div`,{className:`status-item ${n}`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{children:t})]})}function q({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,H.jsxs)(`div`,{className:`metric ${n}`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function J({label:e,value:t,mono:n=!1}){return(0,H.jsxs)(`div`,{className:`summary-item`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function qe({rows:e}){let{t}=Ce();return(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`audit.id`)}),(0,H.jsx)(`th`,{children:t(`audit.commandID`)}),(0,H.jsx)(`th`,{children:t(`audit.action`)}),(0,H.jsx)(`th`,{children:t(`audit.actor`)}),(0,H.jsx)(`th`,{children:t(`audit.status`)}),(0,H.jsx)(`th`,{children:t(`audit.dryRun`)}),(0,H.jsx)(`th`,{children:t(`audit.reason`)}),(0,H.jsx)(`th`,{children:t(`audit.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,H.jsx)(`td`,{children:e.Action}),(0,H.jsx)(`td`,{children:e.Actor}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.DryRun?t(`common.yes`):t(`common.no`)}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,H.jsx)(`td`,{children:ze(e.CreatedAt)})]},e.ID)),e.length===0&&(0,H.jsx)(Je,{colSpan:8})]})]})})}function Je({colSpan:e}){let{t}=Ce();return(0,H.jsx)(`tr`,{children:(0,H.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:t(`common.noResults`)})})}function Ye({label:e}){return(0,H.jsx)(`section`,{className:`surface`,children:(0,H.jsx)(`div`,{className:`loading-line`,children:e})})}function Xe({value:e}){return(0,H.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function Ze({onLogin:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1);async function c(t){t.preventDefault(),s(!0),a(``);try{e((await x.login(n)).actor)}catch(e){a(b(e))}finally{s(!1)}}return(0,H.jsx)(`main`,{className:`login-page`,children:(0,H.jsxs)(`section`,{className:`login-panel`,children:[(0,H.jsxs)(`div`,{className:`login-head`,children:[(0,H.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:t(`app.adminConsole`)})]})]}),(0,H.jsxs)(`div`,{className:`login-head-actions`,children:[(0,H.jsx)(we,{}),(0,H.jsx)(`span`,{className:`login-chip`,children:t(`app.localAccess`)})]})]}),(0,H.jsxs)(`div`,{className:`login-copy`,children:[(0,H.jsx)(`h1`,{children:t(`login.heading`)}),(0,H.jsx)(`p`,{children:t(`login.body`)})]}),i&&(0,H.jsx)(Ke,{children:i}),(0,H.jsxs)(`form`,{className:`form-stack`,onSubmit:c,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:t(`login.secret`)}),(0,H.jsx)(`input`,{autoFocus:!0,type:`password`,value:n,autoComplete:`current-password`,onChange:e=>r(e.target.value)})]}),(0,H.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:o,children:t(o?`login.submitting`:`login.submit`)})]})]})})}var Qe=m();function $e({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,onDone:o}){let{t:s}=Ce(),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(!1);function y(){d(``),p(null),h(``)}async function S(e){if(!u.trim()){h(s(`action.reasonRequired`));return}v(!0),h(``);try{let r={...n(),reason:u,confirm:e};p(await x.action(t,r)),e&&o?.()}catch(e){h(b(e))}finally{v(!1)}}let C=f?.dry_run&&!f.error,w=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,T=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:b(e)}}},[c,n]);return(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:w,type:`button`,onClick:()=>{y(),l(!0)},children:[r,e]}),c&&(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:s(`action.flow`)}),(0,H.jsx)(`h2`,{children:e})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>l(!1),"aria-label":s(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${u.trim()?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:s(`action.stepReason`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${f?.dry_run?`done`:u.trim()?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:s(`action.stepDryRun`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${f&&!f.dry_run&&!f.error?`done`:C?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:s(`action.stepConfirm`)})]})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:s(`action.reason`)}),(0,H.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:3,placeholder:s(`action.reasonPlaceholder`)})]}),(0,H.jsxs)(`div`,{className:`command-preview`,children:[(0,H.jsxs)(`div`,{className:`preview-head`,children:[(0,H.jsx)(te,{size:14}),` `,s(`action.requestPreview`)]}),(0,H.jsx)(Xe,{value:JSON.stringify(T,null,2)})]}),m&&(0,H.jsx)(Ke,{children:m}),f&&(0,H.jsxs)(`div`,{className:`result-box`,children:[(0,H.jsxs)(`div`,{className:`result-title`,children:[f.error?(0,H.jsx)(O,{size:16}):(0,H.jsx)(k,{size:16}),(0,H.jsx)(`strong`,{children:f.message||f.error||s(`action.result`)})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.commandID`)}),(0,H.jsx)(`strong`,{children:f.command_id})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.status`)}),(0,H.jsx)(`strong`,{children:f.status})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:s(`action.dryRun`)}),(0,H.jsx)(`strong`,{children:f.dry_run?s(`common.yes`):s(`common.no`)})]}),(0,H.jsx)(`div`,{className:`result-message`,children:f.message||f.error}),f.details&&(0,H.jsx)(Xe,{value:JSON.stringify(f.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>l(!1),children:s(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(!1),disabled:_,children:[_?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(ue,{size:15}),s(f?`action.runAgain`:`action.runDry`)]}),(0,H.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>S(!0),disabled:_||!C,children:[(0,H.jsx)(k,{size:15}),s(`action.confirm`)]})]})]})}),document.body)]})}function et({rows:e,userID:t,onDone:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(()=>new Set);(0,g.useEffect)(()=>{a(new Set)},[t]);let o=(0,g.useMemo)(()=>e.filter(e=>!i.has(e.Hash)),[e,i]);function s(e){a(t=>e(t)),n()}return(0,H.jsxs)(`div`,{className:`authorization-block`,children:[(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:r(`auth.device`)}),(0,H.jsx)(`th`,{children:r(`auth.platform`)}),(0,H.jsx)(`th`,{children:r(`auth.ip`)}),(0,H.jsx)(`th`,{children:r(`auth.lastActive`)}),(0,H.jsx)(`th`,{className:`device-actions-head`,children:r(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[o.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{className:`device-text`,children:[n.DeviceModel,` `,n.SystemVersion]}),(0,H.jsxs)(`td`,{className:`device-text`,children:[n.Platform,` `,n.AppVersion]}),(0,H.jsx)(`td`,{children:n.IP}),(0,H.jsx)(`td`,{children:ze(n.ActiveAt)}),(0,H.jsx)(`td`,{className:`device-actions-cell`,children:(0,H.jsxs)(`div`,{className:`device-actions`,children:[(0,H.jsx)($e,{label:r(`auth.revokeCurrent`),icon:(0,H.jsx)(se,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:n.Hash}),onDone:()=>s(e=>new Set([...e,n.Hash]))}),(0,H.jsx)($e,{label:r(`auth.keepCurrent`),icon:(0,H.jsx)(fe,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:n.Hash}),onDone:()=>s(()=>new Set(e.filter(e=>e.Hash!==n.Hash).map(e=>e.Hash)))})]})})]},n.Hash)),o.length===0&&(0,H.jsx)(Je,{colSpan:5})]})]})}),(0,H.jsx)(`div`,{className:`danger-zone`,children:(0,H.jsx)($e,{label:r(`auth.revokeAll`),icon:(0,H.jsx)(N,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:()=>s(()=>new Set(e.map(e=>e.Hash)))})})]})}function tt({id:e,navigate:t}){let{t:n}=Ce(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(`1000`),[p,m]=(0,g.useState)(()=>nt(new Date(Date.now()+7*864e5))),[h,_]=(0,g.useState)(``);async function v(){c(!0),o(``);try{let t=await x.account(e);i(t),t.Restriction.Frozen&&(t.Restriction.Until&&m(nt(new Date(t.Restriction.Until))),_(t.Restriction.AppealURL||``))}catch(e){o(b(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{v()},[e]),a)return(0,H.jsx)(Ke,{children:a});if(!r)return(0,H.jsx)(Ye,{label:n(s?`account.loadingDetail`:`account.waitingData`)});let y=r.Account;return(0,H.jsx)(He,{title:n(`account.detailTitle`,{id:y.ID}),eyebrow:n(`account.profile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,H.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:Le(y)}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(y.Username)||n(`account.noUsername`),` · `,Fe(y.Phone)||n(`account.noPhone`)]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[y.PremiumUntil>0?(0,H.jsx)(G,{tone:`good`,children:n(`account.premium`)}):(0,H.jsx)(G,{children:n(`account.notPremium`)}),r.Verified?(0,H.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(G,{children:n(`account.notVerified`)}),y.Frozen?(0,H.jsx)(G,{tone:`danger`,children:n(`account.accountFrozen`)}):(0,H.jsx)(G,{children:n(`account.accountActive`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:n(`account.userID`),value:String(y.ID),mono:!0}),(0,H.jsx)(J,{label:n(`account.lastActive`),value:Be(r.LastSeenAt)||`-`}),(0,H.jsx)(J,{label:n(`account.premiumUntil`),value:y.PremiumUntil>0?Be(y.PremiumUntil):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.starsBalance`),value:`${r.StarsBalance} / ${r.StarsGranted?n(`account.startingGrantApplied`):n(`account.startingGrantPending`)}`}),(0,H.jsx)(J,{label:n(`common.updatedAt`),value:ze(y.UpdatedAt)||`-`}),(0,H.jsx)(J,{label:n(`account.activeSessions`),value:String(r.Authorizations.length)}),(0,H.jsx)(J,{label:n(`account.accountFlags`),value:`support=${r.Support} bot=${r.Bot}`}),(0,H.jsx)(J,{label:n(`account.restriction`),value:r.HasRestriction?r.Restriction.Reason||n(`account.restricted`):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeSince`),value:r.Restriction.Since?ze(r.Restriction.Since):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeUntil`),value:r.Restriction.Until?ze(r.Restriction.Until):n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.freezeAppealURL`),value:r.Restriction.AppealURL||n(`common.none`)}),(0,H.jsx)(J,{label:n(`account.createdAt`),value:ze(y.CreatedAt)||`-`})]}),r.About&&(0,H.jsx)(`p`,{className:`about-text`,children:r.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.authorizationsTitle`),text:n(`account.authorizationsCount`,{count:r.Authorizations.length})}),(0,H.jsx)(et,{rows:r.Authorizations,userID:y.ID,onDone:v})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(qe,{rows:r.AuditLogs})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`account.actionDock`)}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeUntil`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeUntilAria`),value:p,onChange:e=>m(e.target.value),type:`datetime-local`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeAppealURL`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeAppealURLAria`),value:h,onChange:e=>_(e.target.value),type:`url`,placeholder:`https://...`})]}),(0,H.jsx)($e,{label:y.Frozen?n(`account.updateFreeze`):n(`account.freezeAccount`),icon:(0,H.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!0,freeze_until:new Date(p).toISOString(),freeze_appeal_url:h.trim()}),onDone:v}),y.Frozen&&(0,H.jsx)($e,{label:n(`account.unfreezeAccount`),icon:(0,H.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!1}),onDone:v}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.premiumMonths`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.premiumMonthsAria`),value:l,onChange:e=>u(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)($e,{label:n(`account.setPremium`),icon:(0,H.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:W(l)}),onDone:v}),(0,H.jsx)($e,{label:n(`account.clearPremium`),icon:(0,H.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:0}),onDone:v}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.starsAmount`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.starsAmountAria`),value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`1000000000`})]}),(0,H.jsx)($e,{label:n(`account.grantStars`),icon:(0,H.jsx)(me,{size:15}),tone:`warn`,path:`/api/actions/grant-stars`,payload:()=>({user_id:y.ID,amount:W(d)}),onDone:v}),(0,H.jsx)($e,{label:r.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,H.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:y.ID,verified:!r.Verified}),onDone:v})]})]})})})}function nt(e){return new Date(e.getTime()-e.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function rt(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function it(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}function at({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeActiveUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_active_us`,String(c.beforeActiveUS)));try{let e=await x.accounts(t);s(e),l({beforeID:e.next_before_id,beforeActiveUS:e.next_before_active_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=rt(o?.rows??[]);return(0,H.jsxs)(He,{title:t(`account.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`account.recentActive`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,H.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`account.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(q,{label:t(`account.onlineDevices`),value:String(h.devices)}),(0,H.jsx)(q,{label:t(`account.premium`),value:String(h.premium),tone:`good`}),(0,H.jsx)(q,{label:t(`account.frozen`),value:String(h.frozen),tone:h.frozen>0?`danger`:`neutral`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`account.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`account.userID`)}),(0,H.jsx)(`th`,{children:t(`account.phone`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`common.name`)}),(0,H.jsx)(`th`,{children:t(`common.device`)}),(0,H.jsx)(`th`,{children:t(`account.lastActive`)}),(0,H.jsx)(`th`,{children:t(`account.premium`)}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`account.frozen`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Fe(n.Phone)}),(0,H.jsx)(`td`,{children:Ie(n.Username)}),(0,H.jsx)(`td`,{children:Le(n)}),(0,H.jsx)(`td`,{children:n.DeviceCount}),(0,H.jsx)(`td`,{children:ze(n.LastActiveAt)}),(0,H.jsx)(`td`,{children:n.PremiumUntil>0?(0,H.jsxs)(G,{tone:`good`,children:[t(`account.premium`),` `,Be(n.PremiumUntil)]}):(0,H.jsx)(G,{children:t(`common.none`)})}),(0,H.jsx)(`td`,{children:n.Verified?(0,H.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(G,{children:t(`account.notVerified`)})}),(0,H.jsx)(`td`,{children:n.Frozen?(0,H.jsx)(G,{tone:`danger`,children:t(`account.frozen`)}):(0,H.jsx)(G,{children:t(`common.normal`)})}),(0,H.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(Je,{colSpan:11})]})]})})]})}function ot({id:e,navigate:t}){let{t:n}=Ce(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.channel(e))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e]),a)return(0,H.jsx)(Ke,{children:a});if(!r)return(0,H.jsx)(Ye,{label:n(`channel.loadingDetail`)});let c=r.Channel;return(0,H.jsx)(He,{title:`${Re(c,n)} #${c.ID}`,eyebrow:n(`channel.detailProfile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,H.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:c.Title||`-`}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(c.Username)||n(`account.noUsername`),` · `,n(`channel.creator`,{id:c.CreatorUserID})]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(G,{children:Re(c,n)}),c.Verified?(0,H.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(G,{children:n(`account.notVerified`)}),c.Deleted?(0,H.jsx)(G,{tone:`danger`,children:n(`common.deleted`)}):(0,H.jsx)(G,{children:n(`common.valid`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:n(`channel.channelID`),value:String(c.ID),mono:!0}),(0,H.jsx)(J,{label:`access_hash`,value:String(c.AccessHash),mono:!0}),(0,H.jsx)(J,{label:n(`common.members`),value:`${c.ParticipantsCount} / ${n(`common.admins`)} ${c.AdminsCount}`}),(0,H.jsx)(J,{label:n(`channel.governance`),value:n(`channel.governanceValue`,{banned:c.BannedCount,kicked:c.KickedCount})}),(0,H.jsx)(J,{label:n(`channel.flags`),value:`broadcast=${c.Broadcast} megagroup=${c.Megagroup} forum=${c.Forum}`}),(0,H.jsx)(J,{label:`top / pinned / PTS`,value:`${c.TopMessageID} / ${c.PinnedMessageID} / ${c.PTS}`}),(0,H.jsx)(J,{label:n(`account.createdAt`),value:Be(c.Date)||`-`}),(0,H.jsx)(J,{label:n(`common.updatedAt`),value:ze(c.UpdatedAt)||`-`})]}),c.About&&(0,H.jsx)(`p`,{className:`about-text`,children:c.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(qe,{rows:r.AuditLogs})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:n(`channel.rawRow`),text:n(`channel.rawRowText`)}),(0,H.jsx)(Xe,{value:r.ChannelJSON})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`channel.actionDock`)}),(0,H.jsx)($e,{label:c.Verified?n(`channel.clearVerified`):n(`channel.setVerified`),icon:(0,H.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:c.ID,verified:!c.Verified}),onDone:s})]})})})}function st({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeUpdatedUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_updated_us`,String(c.beforeUpdatedUS)));try{let e=await x.channels(t);s(e),l({beforeID:e.next_before_id,beforeUpdatedUS:e.next_before_updated_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=it(o?.rows??[]);return(0,H.jsxs)(He,{title:t(`channel.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`channel.recentUpdated`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,H.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`channel.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(q,{label:t(`channel.megagroups`),value:String(h.megagroups)}),(0,H.jsx)(q,{label:t(`channel.broadcasts`),value:String(h.broadcasts)}),(0,H.jsx)(q,{label:t(`channel.verifiedCount`),value:String(h.verified),tone:`good`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`channel.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,H.jsx)(A,{size:15,className:`spin`}):(0,H.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`channel.channelID`)}),(0,H.jsx)(`th`,{children:t(`channel.kind`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`channel.title`)}),(0,H.jsx)(`th`,{children:t(`common.members`)}),(0,H.jsx)(`th`,{children:t(`common.admins`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Re(n,t)}),(0,H.jsx)(`td`,{children:Ie(n.Username)}),(0,H.jsx)(`td`,{children:n.Title}),(0,H.jsx)(`td`,{children:n.ParticipantsCount}),(0,H.jsx)(`td`,{children:n.AdminsCount}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.Verified?(0,H.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(G,{children:t(`account.notVerified`)})}),(0,H.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(Je,{colSpan:10})]})]})})]})}function ct({navigate:e}){let{t}=Ce();return(0,H.jsxs)(`div`,{className:`dashboard-layout`,children:[(0,H.jsxs)(`section`,{className:`overview-band`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:t(`dashboard.eyebrow`)}),(0,H.jsx)(`h2`,{children:t(`dashboard.title`)})]}),(0,H.jsxs)(`div`,{className:`overview-metrics`,children:[(0,H.jsx)(K,{label:t(`dashboard.readPath`),value:t(`dashboard.readPathValue`),tone:`neutral`}),(0,H.jsx)(K,{label:t(`dashboard.writePath`),value:`Admin API`,tone:`good`}),(0,H.jsx)(K,{label:t(`dashboard.executionPolicy`),value:t(`dashboard.dryRunFirst`),tone:`warn`})]})]}),(0,H.jsxs)(`div`,{className:`command-grid`,children:[(0,H.jsx)(lt,{icon:(0,H.jsx)(_e,{}),title:t(`route.accounts`),text:t(`dashboard.accountsText`),href:`/accounts`,navigate:e}),(0,H.jsx)(lt,{icon:(0,H.jsx)(fe,{}),title:t(`route.channels`),text:t(`dashboard.channelsText`),href:`/channels`,navigate:e}),(0,H.jsx)(lt,{icon:(0,H.jsx)(ce,{}),title:t(`route.messages`),text:t(`dashboard.messagesText`),href:`/messages`,navigate:e})]}),(0,H.jsxs)(`section`,{className:`work-strip`,children:[(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(k,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.dryRun`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(ae,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.token`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(L,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.pagination`)})]}),(0,H.jsxs)(`div`,{className:`strip-item`,children:[(0,H.jsx)(te,{size:16}),(0,H.jsx)(`span`,{children:t(`dashboard.strip.snapshot`)})]})]})]})}function lt({icon:e,title:t,text:n,href:r,navigate:i}){return(0,H.jsxs)(je,{className:`launcher`,href:r,navigate:i,children:[(0,H.jsx)(`span`,{className:`launcher-icon`,children:e}),(0,H.jsxs)(`span`,{className:`launcher-copy`,children:[(0,H.jsx)(`strong`,{children:t}),(0,H.jsx)(`span`,{children:n})]}),(0,H.jsx)(I,{size:16})]})}function ut({channelID:e,msgID:t,navigate:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.groupMessage(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(Ke,{children:o});if(!i)return(0,H.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(He,{title:r(`messages.groupDetailTitle`,{id:l.ID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,H.jsx)(M,{size:15}),` `,r(`messages.backGroup`)]}),children:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.channelGroupTitle`,{id:l.ChannelID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.SenderUserID,date:Be(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(G,{children:r(`common.survived`)}),l.Pinned&&(0,H.jsx)(G,{tone:`warn`,children:r(`messages.pinned`)}),l.Post&&(0,H.jsx)(G,{children:r(`messages.channelPost`)}),(0,H.jsxs)(G,{children:[`pts `,l.PTS]})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:r(`common.messageId`),value:String(l.ID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.channelGroup`),value:String(l.ChannelID),mono:!0}),(0,H.jsx)(J,{label:`From Peer`,value:`${l.FromPeerType}:${l.FromPeerID}`,mono:!0}),(0,H.jsx)(J,{label:r(`common.views`),value:String(l.ViewsCount)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelMessageRow`),text:r(`messages.channelMessagesSnapshot`)}),(0,H.jsx)(Xe,{value:i.MessageJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelRow`),text:r(`messages.channelSnapshot`)}),(0,H.jsx)(Xe,{value:i.ChannelJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.channelUpdateEvents`),text:r(`messages.channelEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.messageId`)}),(0,H.jsx)(`th`,{children:r(`common.sender`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:e.MessageID}),(0,H.jsx)(`td`,{children:e.SenderUserID}),(0,H.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),i.UpdateEvents.length===0&&(0,H.jsx)(Je,{colSpan:6})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.eventJson`)}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[i.UpdateEvents.map(e=>(0,H.jsx)(Xe,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),i.UpdateEvents.length===0&&(0,H.jsx)(`div`,{className:`empty-panel`,children:r(`common.noResults`)})]})]})]})})}function dt({label:e,value:t,onChange:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.accounts(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(ve,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(P,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Le(t)}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:Ie(t.Username)||Fe(t.Phone)||`-`})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.userPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:Le(e)}),(0,H.jsx)(`span`,{children:Ie(e.Username)||Fe(e.Phone)||`-`}),e.Verified?(0,H.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,H.jsx)(G,{children:r(`picker.regular`)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function ft({label:e,value:t,onChange:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.channels(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(ve,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(P,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:t.Title||`-`}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:Ie(t.Username)||Re(t,r)})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.channelPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:e.Title||`-`}),(0,H.jsx)(`span`,{children:Ie(e.Username)||Re(e,r)}),e.Verified?(0,H.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,H.jsx)(G,{children:Re(e,r)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function pt({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`100`),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``);async function m(e=!1){if(p(``),!n){p(t(`messages.selectChannel`));return}let r=new URLSearchParams({channel_id:String(n.ID),limit:c});if(e&&u?.rows.length){let e=u.rows[u.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.ID)),a(String(e.Date)),s(String(e.ID))}else i&&r.set(`before_date`,i),o&&r.set(`before_id`,o);try{d(await x.groupMessages(r))}catch(e){p(b(e))}}function h(e){r(e),a(``),s(``),d(null)}let _=u?.rows??[];return(0,H.jsxs)(He,{title:t(`messages.groupTitle`),eyebrow:t(`messages.groupEyebrow`),children:[f&&(0,H.jsx)(Ke,{children:f}),(0,H.jsxs)(Ue,{children:[(0,H.jsx)(`div`,{className:`message-selector-grid single`,children:(0,H.jsx)(ft,{label:t(`messages.channelGroup`),value:n,onChange:h})}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),_.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`messages.currentPage`),value:String(_.length)}),(0,H.jsx)(q,{label:t(`messages.mediaCount`),value:String(_.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,H.jsx)(q,{label:t(`messages.channelPosts`),value:String(_.filter(e=>e.Post).length)}),(0,H.jsx)(q,{label:t(`messages.channelGroup`),value:n?`${n.Title||Re(n,t)} (${n.ID})`:`-`})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:`From Peer`}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.views`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[_.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Be(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.SenderUserID}),(0,H.jsxs)(`td`,{className:`mono`,children:[n.FromPeerType,`:`,n.FromPeerID]}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.ViewsCount}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):n.Pinned?(0,H.jsx)(G,{tone:`warn`,children:t(`messages.pinned`)}):(0,H.jsx)(G,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${n.ChannelID}&msg_id=${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},`${n.ChannelID}-${n.ID}`)),_.length===0&&(0,H.jsx)(Je,{colSpan:9})]})]})})]})}function mt({ownerUserID:e,msgID:t,navigate:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.message(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(Ke,{children:o});if(!i)return(0,H.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(He,{title:r(`messages.privateDetailTitle`,{id:l.BoxID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,H.jsx)(M,{size:15}),` `,r(`messages.backPrivate`)]}),children:(0,H.jsx)(We,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.ownerPeerTitle`,{owner:l.OwnerUserID,peer:l.PeerID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.FromUserID,date:Be(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(G,{children:r(`common.survived`)}),(0,H.jsxs)(G,{children:[`pts `,l.PTS]}),(0,H.jsx)(G,{children:l.Outgoing?r(`messages.outgoing`):r(`messages.incoming`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(J,{label:r(`messages.boxID`),value:String(l.BoxID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.privateMessageID`),value:String(l.PrivateMessageID),mono:!0}),(0,H.jsx)(J,{label:r(`messages.messageSender`),value:String(l.MessageSenderID),mono:!0}),(0,H.jsx)(J,{label:r(`common.time`),value:Be(l.Date)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.messageBox`),text:r(`messages.messageBoxesSnapshot`)}),(0,H.jsx)(Xe,{value:i.MessageJSON})]}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.dialogRow`),text:r(`messages.dialogSnapshot`)}),(0,H.jsx)(Xe,{value:i.DialogJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.privateRow`),text:r(`messages.privateSnapshot`)}),(0,H.jsx)(Xe,{value:i.PrivateJSON})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.userUpdateEvents`),text:r(`messages.userEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}`)),i.UpdateEvents.length===0&&(0,H.jsx)(Je,{colSpan:4})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(Ge,{title:r(`messages.dispatchOutbox`),text:r(`messages.outboxSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`ID`}),(0,H.jsx)(`th`,{children:r(`account.userID`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.status`)}),(0,H.jsx)(`th`,{children:r(`messages.attempts`)}),(0,H.jsx)(`th`,{children:r(`common.updatedAt`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.Outbox.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{children:e.TargetUserID}),(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.EventType}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.Attempts}),(0,H.jsx)(`td`,{children:ze(e.UpdatedAt)})]},e.ID)),i.Outbox.length===0&&(0,H.jsx)(Je,{colSpan:7})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:r(`common.operations`)}),(0,H.jsx)($e,{label:r(`messages.deleteThis`),icon:(0,H.jsx)(he,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:l.OwnerUserID,peer_id:l.PeerID,ids:[l.BoxID],revoke:!0}),onDone:c})]})})})}function ht({navigate:e}){let{t}=Ce(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`100`),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!0),[_,v]=(0,g.useState)(!1),[y,S]=(0,g.useState)(``),[C,w]=(0,g.useState)(`1`),[T,E]=(0,g.useState)(null),[D,O]=(0,g.useState)(``);async function k(e=!1){if(O(``),!n||!i){O(t(`messages.selectPrivatePeers`));return}let r=new URLSearchParams({owner_user_id:String(n.ID),peer_id:String(i.ID),limit:u});if(e&&T?.rows.length){let e=T.rows[T.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.BoxID)),s(String(e.Date)),l(String(e.BoxID))}else o&&r.set(`before_date`,o),c&&r.set(`before_id`,c);try{E(await x.messages(r))}catch(e){O(b(e))}}function A(e){r(e),s(``),l(``),E(null)}function j(e){a(e),s(``),l(``),E(null)}return(0,H.jsxs)(He,{title:t(`messages.privateTitle`),eyebrow:t(`messages.privateEyebrow`),children:[D&&(0,H.jsx)(Ke,{children:D}),(0,H.jsxs)(Ue,{children:[(0,H.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,H.jsx)(dt,{label:t(`messages.ownerUser`),value:n,onChange:A}),(0,H.jsx)(dt,{label:t(`messages.peerUser`),value:i,onChange:j})]}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),k(!1)},children:[(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),T?.rows.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>k(!0),children:[(0,H.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(q,{label:t(`messages.currentPage`),value:String(T?.rows.length??0)}),(0,H.jsx)(q,{label:t(`messages.deleted`),value:String((T?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,H.jsx)(q,{label:t(`messages.outgoing`),value:String((T?.rows??[]).filter(e=>e.Outgoing).length)}),(0,H.jsx)(q,{label:t(`messages.ownerPeer`),value:n&&i?`${Le(n)} / ${Le(i)}`:`-`})]}),(0,H.jsxs)(`div`,{className:`operation-row`,children:[(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(he,{size:15}),` `,t(`messages.deleteSelected`)]}),(0,H.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:t(`messages.idsPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsx)($e,{path:`/api/actions/delete-messages`,label:t(`messages.previewDelete`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,ids:Ve(f,t(`messages.msgIDsInvalid`)),revoke:m})})]}),(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(ie,{size:15}),` `,t(`messages.clearHistory`)]}),(0,H.jsx)(`input`,{value:y,onChange:e=>S(e.target.value),placeholder:t(`messages.maxIDPlaceholder`)}),(0,H.jsx)(`input`,{value:C,onChange:e=>w(e.target.value),placeholder:t(`messages.maxBatchesPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),` `,t(`messages.justClear`)]}),(0,H.jsx)($e,{path:`/api/actions/delete-history`,label:t(`messages.previewClearHistory`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,max_id:W(y),max_batches:W(C),just_clear:_,revoke:m})})]})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:t(`messages.direction`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[T?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.BoxID}),(0,H.jsx)(`td`,{children:Be(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.FromUserID}),(0,H.jsx)(`td`,{children:n.Outgoing?t(`messages.outgoing`):t(`messages.incoming`)}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):(0,H.jsx)(G,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${n.OwnerUserID}&msg_id=${n.BoxID}`),children:[t(`common.detail`),` `,(0,H.jsx)(I,{size:14})]})})]},`${n.OwnerUserID}-${n.BoxID}`)),(!T||T.rows.length===0)&&(0,H.jsx)(Je,{colSpan:8})]})]})})]})}var gt=c(o(((e,t)=>{typeof document<`u`&&typeof navigator<`u`&&(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.lottie=r())})(e,(function(){var n=``,r=!1,i=-999999,a=function(e){r=!!e},o=function(){return r},s=function(e){n=e},c=function(){return n};function l(e){return document.createElement(e)}function u(e,t){var n,r=e.length,i;for(n=0;n1?n[1]=1:n[1]<=0&&(n[1]=0),L(n[0],n[1],n[2])}function te(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[2]+=t,n[2]>1?n[2]=1:n[2]<0&&(n[2]=0),L(n[0],n[1],n[2])}function ne(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[0]+=t/360,n[0]>1?--n[0]:n[0]<0&&(n[0]+=1),L(n[0],n[1],n[2])}(function(){var e=[],t,n;for(t=0;t<256;t+=1)n=t.toString(16),e[t]=n.length===1?`0`+n:n;return function(t,n,r){return t<0&&(t=0),n<0&&(n=0),r<0&&(r=0),`#`+e[t]+e[n]+e[r]}})();var re=function(e){g=!!e},ie=function(){return g},ae=function(e){_=e},oe=function(){return _},se=function(){return v},ce=function(e){E=e},le=function(){return E},ue=function(e){y=e};function z(e){return document.createElementNS(`http://www.w3.org/2000/svg`,e)}function de(e){"@babel/helpers - typeof";return de=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},de(e)}var B=function(){var e=1,t=[],n,r,i={onmessage:function(){},postMessage:function(e){n({data:e})}},a={postMessage:function(e){i.onmessage({data:e})}};function s(e){if(window.Worker&&window.Blob&&o()){var t=new Blob([`var _workerSelf = self; self.onmessage = `,e.toString()],{type:`text/javascript`}),r=URL.createObjectURL(t);return new Worker(r)}return n=e,i}function c(){r||(r=s(function(e){function t(){function e(t,n){var o,s,c=t.length,l,u,d,f;for(s=0;s=0;--t)if(e[t].ty===`sh`)if(e[t].ks.k.i)a(e[t].ks.k);else for(o=e[t].ks.k.length,r=0;rn[0]?!0:n[0]>e[0]?!1:e[1]>n[1]?!0:n[1]>e[1]?!1:e[2]>n[2]?!0:n[2]>e[2]?!1:null}var s=function(){var e=[4,4,14];function t(e){var t=e.t.d;e.t.d={k:[{s:t,t:0}]}}function n(e){var n,r=e.length;for(n=0;n=0;--n)if(e[n].ty===`sh`)if(e[n].ks.k.i)e[n].ks.k.c=e[n].closed;else for(a=e[n].ks.k.length,i=0;i500)&&(this._imageLoaded(),clearInterval(n)),t+=1}.bind(this),50)}function a(t){var n=r(t,this.assetsPath,this.path),i=z(`image`);b?this.testImageLoaded(i):i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.setAttributeNS(`http://www.w3.org/1999/xlink`,`href`,n),this._elementHelper.append?this._elementHelper.append(i):this._elementHelper.appendChild(i);var a={img:i,assetData:t};return a}function o(t){var n=r(t,this.assetsPath,this.path),i=l(`img`);i.crossOrigin=`anonymous`,i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.src=n;var a={img:i,assetData:t};return a}function s(e){var t={assetData:e},n=r(e,this.assetsPath,this.path);return B.loadData(n,function(e){t.img=e,this._footageLoaded()}.bind(this),function(){t.img={},this._footageLoaded()}.bind(this)),t}function c(e,t){this.imagesLoadedCb=t;var n,r=e.length;for(n=0;nthis.animationData.op&&(this.animationData.op=e.op,this.totalFrames=Math.floor(e.op-this.animationData.ip));var t=this.animationData.layers,n,r=t.length,i=e.layers,a,o=i.length;for(a=0;athis.timeCompleted&&(this.currentFrame=this.timeCompleted),this.trigger(`enterFrame`),this.renderFrame(),this.trigger(`drawnFrame`)},H.prototype.renderFrame=function(){if(!(this.isLoaded===!1||!this.renderer))try{this.expressionsPlugin&&this.expressionsPlugin.resetFrame(),this.renderer.renderFrame(this.currentFrame+this.firstFrame)}catch(e){this.triggerRenderFrameError(e)}},H.prototype.play=function(e){e&&this.name!==e||this.isPaused===!0&&(this.isPaused=!1,this.trigger(`_play`),this.audioController.resume(),this._idle&&(this._idle=!1,this.trigger(`_active`)))},H.prototype.pause=function(e){e&&this.name!==e||this.isPaused===!1&&(this.isPaused=!0,this.trigger(`_pause`),this._idle=!0,this.trigger(`_idle`),this.audioController.pause())},H.prototype.togglePause=function(e){e&&this.name!==e||(this.isPaused===!0?this.play():this.pause())},H.prototype.stop=function(e){e&&this.name!==e||(this.pause(),this.playCount=0,this._completedLoop=!1,this.setCurrentRawFrameValue(0))},H.prototype.getMarkerData=function(e){for(var t,n=0;n=this.totalFrames-1&&this.frameModifier>0?!this.loop||this.playCount===this.loop?this.checkSegments(t>this.totalFrames?t%this.totalFrames:0)||(n=!0,t=this.totalFrames-1):t>=this.totalFrames?(this.playCount+=1,this.checkSegments(t%this.totalFrames)||(this.setCurrentRawFrameValue(t%this.totalFrames),this._completedLoop=!0,this.trigger(`loopComplete`))):this.setCurrentRawFrameValue(t):t<0?this.checkSegments(t%this.totalFrames)||(this.loop&&!(this.playCount--<=0&&this.loop!==!0)?(this.setCurrentRawFrameValue(this.totalFrames+t%this.totalFrames),this._completedLoop?this.trigger(`loopComplete`):this._completedLoop=!0):(n=!0,t=0)):this.setCurrentRawFrameValue(t),n&&(this.setCurrentRawFrameValue(t),this.pause(),this.trigger(`complete`))}},H.prototype.adjustSegment=function(e,t){this.playCount=0,e[1]0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(-1)),this.totalFrames=e[0]-e[1],this.timeCompleted=this.totalFrames,this.firstFrame=e[1],this.setCurrentRawFrameValue(this.totalFrames-.001-t)):e[1]>e[0]&&(this.frameModifier<0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(1)),this.totalFrames=e[1]-e[0],this.timeCompleted=this.totalFrames,this.firstFrame=e[0],this.setCurrentRawFrameValue(.001+t)),this.trigger(`segmentStart`)},H.prototype.setSegment=function(e,t){var n=-1;this.isPaused&&(this.currentRawFrame+this.firstFramet&&(n=t-e)),this.firstFrame=e,this.totalFrames=t-e,this.timeCompleted=this.totalFrames,n!==-1&&this.goToAndStop(n,!0)},H.prototype.playSegments=function(e,t){if(t&&(this.segments.length=0),ye(e[0])===`object`){var n,r=e.length;for(n=0;n=0;--n)t[n].animation.destroy(e)}function T(e,t,n){var r=[].concat([].slice.call(document.getElementsByClassName(`lottie`)),[].slice.call(document.getElementsByClassName(`bodymovin`))),i,a=r.length;for(i=0;i0?n=c:t=c;while(Math.abs(s)>a&&++l=i?g(e,d,t,n):f===0?d:h(e,a,a+c,t,n)}},e}(),xe=function(){function e(e){return e.concat(m(e.length))}return{double:e}}(),Se=function(){return function(e,t,n){var r=0,i=e,a=m(i),o={newElement:s,release:c};function s(){var e;return r?(--r,e=a[r]):e=t(),e}function c(e){r===i&&(a=xe.double(a),i*=2),n&&n(e),a[r]=e,r+=1}return o}}(),Ce=function(){function e(){return{addedLength:0,percents:p(`float32`,le()),lengths:p(`float32`,le())}}return Se(8,e)}(),we=function(){function e(){return{lengths:[],totalLength:0}}function t(e){var t,n=e.lengths.length;for(t=0;t-.001&&o<.001}function n(n,r,i,a,o,s,c,l,u){if(i===0&&s===0&&u===0)return t(n,r,a,o,c,l);var d=e.sqrt(e.pow(a-n,2)+e.pow(o-r,2)+e.pow(s-i,2)),f=e.sqrt(e.pow(c-n,2)+e.pow(l-r,2)+e.pow(u-i,2)),p=e.sqrt(e.pow(c-a,2)+e.pow(l-o,2)+e.pow(u-s,2)),m=d>f?d>p?d-f-p:p-f-d:p>f?p-f-d:f-d-p;return m>-1e-4&&m<1e-4}var r=function(){return function(e,t,n,r){var i=le(),a,o,s,c,l,u=0,d,f=[],p=[],m=Ce.newElement();for(s=n.length,a=0;ao?-1:1,l=!0;l;)if(r[a]<=o&&r[a+1]>o?(s=(o-r[a])/(r[a+1]-r[a]),l=!1):a+=c,a<0||a>=i-1){if(a===i-1)return n[a];l=!1}return n[a]+(n[a+1]-n[a])*s}function l(t,n,r,i,a,o){var s=c(a,o),l=1-s;return[e.round((l*l*l*t[0]+(s*l*l+l*s*l+l*l*s)*r[0]+(s*s*l+l*s*s+s*l*s)*i[0]+s*s*s*n[0])*1e3)/1e3,e.round((l*l*l*t[1]+(s*l*l+l*s*l+l*l*s)*r[1]+(s*s*l+l*s*s+s*l*s)*i[1]+s*s*s*n[1])*1e3)/1e3]}var u=p(`float32`,8);function d(t,n,r,i,a,o,s){a<0?a=0:a>1&&(a=1);var l=c(a,s);o=o>1?1:o;var d=c(o,s),f,p=t.length,m=1-l,h=1-d,g=m*m*m,_=l*m*m*3,v=l*l*m*3,y=l*l*l,b=m*m*h,x=l*m*h+m*l*h+m*m*d,S=l*l*h+m*l*d+l*m*d,C=l*l*d,w=m*h*h,T=l*h*h+m*d*h+m*h*d,E=l*d*h+m*d*d+l*h*d,D=l*d*d,O=h*h*h,k=d*h*h+h*d*h+h*h*d,A=d*d*h+h*d*d+d*h*d,j=d*d*d;for(f=0;f=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a=v||e=v?x.points.length-1:0;for(f=x.points[S].point.length,d=0;d=T&&C=v)r[0]=b[0],r[1]=b[1],r[2]=b[2];else if(e<=y)r[0]=c.s[0],r[1]=c.s[1],r[2]=c.s[2];else{var j=Me(c.s),M=Me(b),N=(e-y)/(v-y);je(r,Ae(j,M,N))}else for(a=0;a=v?m=1:e1e-6?(f=Math.acos(p),m=Math.sin(f),h=Math.sin((1-n)*f)/m,g=Math.sin(n*f)/m):(h=1-n,g=n),r[0]=h*i+g*c,r[1]=h*a+g*l,r[2]=h*o+g*u,r[3]=h*s+g*d,r}function je(e,t){var n=t[0],r=t[1],i=t[2],a=t[3],o=Math.atan2(2*r*a-2*n*i,1-2*r*r-2*i*i),s=Math.asin(2*n*r+2*i*a),c=Math.atan2(2*n*a-2*r*i,1-2*n*n-2*i*i);e[0]=o/D,e[1]=s/D,e[2]=c/D}function Me(e){var t=e[0]*D,n=e[1]*D,r=e[2]*D,i=Math.cos(t/2),a=Math.cos(n/2),o=Math.cos(r/2),s=Math.sin(t/2),c=Math.sin(n/2),l=Math.sin(r/2),u=i*a*o-s*c*l;return[s*c*o+i*a*l,s*a*o+i*c*l,i*c*o-s*a*l,u]}function Ne(){var e=this.comp.renderedFrame-this.offsetTime,t=this.keyframes[0].t-this.offsetTime,n=this.keyframes[this.keyframes.length-1].t-this.offsetTime;if(!(e===this._caching.lastFrame||this._caching.lastFrame!==De&&(this._caching.lastFrame>=n&&e>=n||this._caching.lastFrame=e&&(this._caching._lastKeyframeIndex=-1,this._caching.lastIndex=0);var r=this.interpolateValue(e,this._caching);this.pv=r}return this._caching.lastFrame=e,this.pv}function Pe(e){var t;if(this.propType===`unidimensional`)t=e*this.mult,Oe(this.v-t)>1e-5&&(this.v=t,this._mdf=!0);else for(var n=0,r=this.v.length;n1e-5&&(this.v[n]=t,this._mdf=!0),n+=1}function Fe(){if(!(this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)){if(this.lock){this.setVValue(this.pv);return}this.lock=!0,this._mdf=this._isFirstFrame;var e,t=this.effectsSequence.length,n=this.kf?this.pv:this.data.k;for(e=0;e=this._maxLength&&this.doubleArrayLength(),n){case`v`:a=this.v;break;case`i`:a=this.i;break;case`o`:a=this.o;break;default:a=[];break}(!a[r]||a[r]&&!i)&&(a[r]=He.newElement()),a[r][0]=e,a[r][1]=t},Ue.prototype.setTripleAt=function(e,t,n,r,i,a,o,s){this.setXYAt(e,t,`v`,o,s),this.setXYAt(n,r,`o`,o,s),this.setXYAt(i,a,`i`,o,s)},Ue.prototype.reverse=function(){var e=new Ue;e.setPathData(this.c,this._length);var t=this.v,n=this.o,r=this.i,i=0;this.c&&(e.setTripleAt(t[0][0],t[0][1],r[0][0],r[0][1],n[0][0],n[0][1],0,!1),i=1);var a=this._length-1,o=this._length,s;for(s=i;s=p[p.length-1].t-this.offsetTime)i=p[p.length-1].s?p[p.length-1].s[0]:p[p.length-2].e[0],o=!0;else{for(var m=r,h=p.length-1,g=!0,_,v,y;g&&(_=p[m],v=p[m+1],!(v.t-this.offsetTime>e));)m=v.t-this.offsetTime)d=1;else if(e<_.t-this.offsetTime)d=0;else{var b;y.__fnct?b=y.__fnct:(b=be.getBezierEasing(_.o.x,_.o.y,_.i.x,_.i.y).get,y.__fnct=b),d=b((e-(_.t-this.offsetTime))/(v.t-this.offsetTime-(_.t-this.offsetTime)))}a=v.s?v.s[0]:_.e[0]}i=_.s[0]}for(l=t._length,u=i.i[0].length,n.lastIndex=r,s=0;sr&&t>r)||(this._caching.lastIndex=i0||e>-1e-6&&e<0?r(e*t)/t:e}function P(){var e=this.props,t=N(e[0]),n=N(e[1]),r=N(e[4]),i=N(e[5]),a=N(e[12]),o=N(e[13]);return`matrix(`+t+`,`+n+`,`+r+`,`+i+`,`+a+`,`+o+`)`}return function(){this.reset=i,this.rotate=a,this.rotateX=o,this.rotateY=s,this.rotateZ=c,this.skew=u,this.skewFromAxis=d,this.shear=l,this.scale=f,this.setTransform=m,this.translate=h,this.transform=g,this.multiply=_,this.applyToPoint=S,this.applyToX=C,this.applyToY=w,this.applyToZ=T,this.applyToPointArray=A,this.applyToTriplePoints=k,this.applyToPointStringified=j,this.toCSS=M,this.to2dCSS=P,this.clone=b,this.cloneFromProps=x,this.equals=y,this.inversePoints=O,this.inversePoint=D,this.getInverseMatrix=E,this._t=this.transform,this.isIdentity=v,this._identity=!0,this._identityCalculated=!1,this.props=p(`float32`,16),this.reset()}}();function q(e){"@babel/helpers - typeof";return q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},q(e)}var J={},qe=`__[STANDALONE]__`,Je=`__[ANIMATIONDATA]__`,Ye=``;function Xe(e){s(e)}function Ze(){qe===!0?U.searchAnimations(Je,qe,Ye):U.searchAnimations()}function Qe(e){re(e)}function $e(e){ue(e)}function et(e){return qe===!0&&(e.animationData=JSON.parse(Je)),U.loadAnimation(e)}function tt(e){if(typeof e==`string`)switch(e){case`high`:ce(200);break;default:case`medium`:ce(50);break;case`low`:ce(10);break}else!isNaN(e)&&e>1&&ce(e)}function nt(){return typeof navigator<`u`}function rt(e,t){e===`expressions`&&ae(t)}function it(e){switch(e){case`propertyFactory`:return W;case`shapePropertyFactory`:return G;case`matrix`:return K;default:return null}}J.play=U.play,J.pause=U.pause,J.setLocationHref=Xe,J.togglePause=U.togglePause,J.setSpeed=U.setSpeed,J.setDirection=U.setDirection,J.stop=U.stop,J.searchAnimations=Ze,J.registerAnimation=U.registerAnimation,J.loadAnimation=et,J.setSubframeRendering=Qe,J.resize=U.resize,J.goToAndStop=U.goToAndStop,J.destroy=U.destroy,J.setQuality=tt,J.inBrowser=nt,J.installPlugin=rt,J.freeze=U.freeze,J.unfreeze=U.unfreeze,J.setVolume=U.setVolume,J.mute=U.mute,J.unmute=U.unmute,J.getRegisteredAnimations=U.getRegisteredAnimations,J.useWebWorker=a,J.setIDPrefix=$e,J.__getFactory=it,J.version=`5.13.0`;function at(){document.readyState===`complete`&&(clearInterval(ut),Ze())}function ot(e){for(var t=st.split(`&`),n=0;n=1?a.push({s:e-1,e:t-1}):(a.push({s:e,e:1}),a.push({s:0,e:t-1}));var o=[],s,c=a.length,l;for(s=0;sr+n)){var u=l.s*i<=r?0:(l.s*i-r)/n,d=l.e*i>=r+n?1:(l.e*i-r)/n;o.push([u,d])}return o.length||o.push([0,0]),o},pt.prototype.releasePathsData=function(e){var t,n=e.length;for(t=0;t1?1+r:this.s.v<0?0+r:this.s.v+r,n=this.e.v>1?1+r:this.e.v<0?0+r:this.e.v+r,t>n){var i=t;t=n,n=i}t=Math.round(t*1e4)*1e-4,n=Math.round(n*1e4)*1e-4,this.sValue=t,this.eValue=n}else t=this.sValue,n=this.eValue;var a,o,s=this.shapes.length,c,l,u,d,f,p=0;if(n===t)for(o=0;o=0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;c=1?m.push({s:h.totalShapeLength*(_-1),e:h.totalShapeLength*(v-1)}):(m.push({s:h.totalShapeLength*_,e:h.totalShapeLength}),m.push({s:0,e:h.totalShapeLength*(v-1)}));var x=this.addShapes(h,m[0]);if(m[0].s!==m[0].e){if(m.length>1)if(h.shape.paths.shapes[h.shape.paths._length-1].c){var S=x.pop();this.addPaths(x,g),x=this.addShapes(h,m[1],S)}else this.addPaths(x,g),x=this.addShapes(h,m[1]);this.addPaths(x,g)}}h.shape.paths=g}}else if(this._mdf)for(o=0;ot.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=this.p.keyframes[this.p.keyframes.length-1].t?(r=this.p.getValueAtTime(this.p.keyframes[this.p.keyframes.length-1].t/n,0),i=this.p.getValueAtTime((this.p.keyframes[this.p.keyframes.length-1].t-.05)/n,0)):(r=this.p.pv,i=this.p.getValueAtTime((this.p._caching.lastFrame+this.p.offsetTime-.01)/n,this.p.offsetTime));else if(this.px&&this.px.keyframes&&this.py.keyframes&&this.px.getValueAtTime&&this.py.getValueAtTime){r=[],i=[];var a=this.px,o=this.py;a._caching.lastFrame+a.offsetTime<=a.keyframes[0].t?(r[0]=a.getValueAtTime((a.keyframes[0].t+.01)/n,0),r[1]=o.getValueAtTime((o.keyframes[0].t+.01)/n,0),i[0]=a.getValueAtTime(a.keyframes[0].t/n,0),i[1]=o.getValueAtTime(o.keyframes[0].t/n,0)):a._caching.lastFrame+a.offsetTime>=a.keyframes[a.keyframes.length-1].t?(r[0]=a.getValueAtTime(a.keyframes[a.keyframes.length-1].t/n,0),r[1]=o.getValueAtTime(o.keyframes[o.keyframes.length-1].t/n,0),i[0]=a.getValueAtTime((a.keyframes[a.keyframes.length-1].t-.01)/n,0),i[1]=o.getValueAtTime((o.keyframes[o.keyframes.length-1].t-.01)/n,0)):(r=[a.pv,o.pv],i[0]=a.getValueAtTime((a._caching.lastFrame+a.offsetTime-.01)/n,a.offsetTime),i[1]=o.getValueAtTime((o._caching.lastFrame+o.offsetTime-.01)/n,o.offsetTime))}else i=e,r=i;this.v.rotate(-Math.atan2(r[1]-i[1],r[0]-i[0]))}this.data.p&&this.data.p.s?this.data.p.z?this.v.translate(this.px.v,this.py.v,-this.pz.v):this.v.translate(this.px.v,this.py.v,0):this.v.translate(this.p.v[0],this.p.v[1],-this.p.v[2])}this.frameId=this.elem.globalData.frameId}}function r(){if(this.appliedTransformations=0,this.pre.reset(),!this.a.effectsSequence.length)this.pre.translate(-this.a.v[0],-this.a.v[1],this.a.v[2]),this.appliedTransformations=1;else return;if(!this.s.effectsSequence.length)this.pre.scale(this.s.v[0],this.s.v[1],this.s.v[2]),this.appliedTransformations=2;else return;if(this.sk)if(!this.sk.effectsSequence.length&&!this.sa.effectsSequence.length)this.pre.skewFromAxis(-this.sk.v,this.sa.v),this.appliedTransformations=3;else return;this.r?this.r.effectsSequence.length||(this.pre.rotate(-this.r.v),this.appliedTransformations=4):!this.rz.effectsSequence.length&&!this.ry.effectsSequence.length&&!this.rx.effectsSequence.length&&!this.or.effectsSequence.length&&(this.pre.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2]).rotateY(this.or.v[1]).rotateX(this.or.v[0]),this.appliedTransformations=4)}function i(){}function a(e){this._addDynamicProperty(e),this.elem.addDynamicProperty(e),this._isDirty=!0}function o(e,t,n){if(this.elem=e,this.frameId=-1,this.propType=`transform`,this.data=t,this.v=new K,this.pre=new K,this.appliedTransformations=0,this.initDynamicPropertyContainer(n||e),t.p&&t.p.s?(this.px=W.getProp(e,t.p.x,0,0,this),this.py=W.getProp(e,t.p.y,0,0,this),t.p.z&&(this.pz=W.getProp(e,t.p.z,0,0,this))):this.p=W.getProp(e,t.p||{k:[0,0,0]},1,0,this),t.rx){if(this.rx=W.getProp(e,t.rx,0,D,this),this.ry=W.getProp(e,t.ry,0,D,this),this.rz=W.getProp(e,t.rz,0,D,this),t.or.k[0].ti){var r,i=t.or.k.length;for(r=0;r0;)--n,this._elements.unshift(t[n]);this.dynamicProperties.length?this.k=!0:this.getValue(!0)},gt.prototype.resetElements=function(e){var t,n=e.length;for(t=0;t0?Math.floor(f):Math.ceil(f),h=this.pMatrix.props,g=this.rMatrix.props,_=this.sMatrix.props;this.pMatrix.reset(),this.rMatrix.reset(),this.sMatrix.reset(),this.tMatrix.reset(),this.matrix.reset();var v=0;if(f>0){for(;vm;)this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!0),--v;p&&(this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,-p,!0),v-=p)}r=this.data.m===1?0:this._currentCopies-1,i=this.data.m===1?1:-1,a=this._currentCopies;for(var y,b;a;){if(t=this.elemsData[r].it,n=t[t.length-1].transform.mProps.v.props,b=n.length,t[t.length-1].transform.mProps._mdf=!0,t[t.length-1].transform.op._mdf=!0,t[t.length-1].transform.op.v=this._currentCopies===1?this.so.v:this.so.v+(this.eo.v-this.so.v)*(r/(this._currentCopies-1)),v!==0){for((r!==0&&i===1||r!==this._currentCopies-1&&i===-1)&&this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!1),this.matrix.transform(g[0],g[1],g[2],g[3],g[4],g[5],g[6],g[7],g[8],g[9],g[10],g[11],g[12],g[13],g[14],g[15]),this.matrix.transform(_[0],_[1],_[2],_[3],_[4],_[5],_[6],_[7],_[8],_[9],_[10],_[11],_[12],_[13],_[14],_[15]),this.matrix.transform(h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8],h[9],h[10],h[11],h[12],h[13],h[14],h[15]),y=0;y0&&r<1?[t]:[]:[t-r,t+r].filter(function(e){return e>0&&e<1})},Tt.prototype.split=function(e){if(e<=0)return[wt(this.points[0]),this];if(e>=1)return[this,wt(this.points[this.points.length-1])];var t=xt(this.points[0],this.points[1],e),n=xt(this.points[1],this.points[2],e),r=xt(this.points[2],this.points[3],e),i=xt(t,n,e),a=xt(n,r,e),o=xt(i,a,e);return[new Tt(this.points[0],t,i,o,!0),new Tt(o,a,r,this.points[3],!0)]};function Et(e,t){var n=e.points[0][t],r=e.points[e.points.length-1][t];if(n>r){var i=r;r=n,n=i}for(var a=St(3*e.a[t],2*e.b[t],e.c[t]),o=0;o0&&a[o]<1){var s=e.point(a[o])[t];sr&&(r=s)}return{min:n,max:r}}Tt.prototype.bounds=function(){return{x:Et(this,0),y:Et(this,1)}},Tt.prototype.boundingBox=function(){var e=this.bounds();return{left:e.x.min,right:e.x.max,top:e.y.min,bottom:e.y.max,width:e.x.max-e.x.min,height:e.y.max-e.y.min,cx:(e.x.max+e.x.min)/2,cy:(e.y.max+e.y.min)/2}};function Dt(e,t,n){var r=e.boundingBox();return{cx:r.cx,cy:r.cy,width:r.width,height:r.height,bez:e,t:(t+n)/2,t1:t,t2:n}}function Ot(e){var t=e.bez.split(.5);return[Dt(t[0],e.t1,e.t),Dt(t[1],e.t,e.t2)]}function kt(e,t){return Math.abs(e.cx-t.cx)*2=a||e.width<=r&&e.height<=r&&t.width<=r&&t.height<=r){i.push([e.t,t.t]);return}var o=Ot(e),s=Ot(t);At(o[0],s[0],n+1,r,i,a),At(o[0],s[1],n+1,r,i,a),At(o[1],s[0],n+1,r,i,a),At(o[1],s[1],n+1,r,i,a)}}Tt.prototype.intersections=function(e,t,n){t===void 0&&(t=2),n===void 0&&(n=7);var r=[];return At(Dt(this,0,1),Dt(e,0,1),0,t,r,n),r},Tt.shapeSegment=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[t],e.o[t],e.i[n],e.v[n],!0)},Tt.shapeSegmentInverted=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[n],e.i[n],e.o[t],e.v[t],!0)};function jt(e,t){return[e[1]*t[2]-e[2]*t[1],e[2]*t[0]-e[0]*t[2],e[0]*t[1]-e[1]*t[0]]}function Mt(e,t,n,r){var i=[e[0],e[1],1],a=[t[0],t[1],1],o=[n[0],n[1],1],s=[r[0],r[1],1],c=jt(jt(i,a),jt(o,s));return yt(c[2])?null:[c[0]/c[2],c[1]/c[2]]}function Y(e,t,n){return[e[0]+Math.cos(t)*n,e[1]-Math.sin(t)*n]}function Nt(e,t){return Math.hypot(e[0]-t[0],e[1]-t[1])}function Pt(e,t){return vt(e[0],t[0])&&vt(e[1],t[1])}function Ft(){}u([ft],Ft),Ft.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amplitude=W.getProp(e,t.s,0,null,this),this.frequency=W.getProp(e,t.r,0,null,this),this.pointsType=W.getProp(e,t.pt,0,null,this),this._isAnimated=this.amplitude.effectsSequence.length!==0||this.frequency.effectsSequence.length!==0||this.pointsType.effectsSequence.length!==0};function It(e,t,n,r,i,a,o){var s=n-Math.PI/2,c=n+Math.PI/2,l=t[0]+Math.cos(n)*r*i,u=t[1]-Math.sin(n)*r*i;e.setTripleAt(l,u,l+Math.cos(s)*a,u-Math.sin(s)*a,l+Math.cos(c)*o,u-Math.sin(c)*o,e.length())}function Lt(e,t){var n=[t[0]-e[0],t[1]-e[1]],r=-Math.PI*.5;return[Math.cos(r)*n[0]-Math.sin(r)*n[1],Math.sin(r)*n[0]+Math.cos(r)*n[1]]}function Rt(e,t){var n=t===0?e.length()-1:t-1,r=(t+1)%e.length(),i=e.v[n],a=e.v[r],o=Lt(i,a);return Math.atan2(0,1)-Math.atan2(o[1],o[0])}function zt(e,t,n,r,i,a,o){var s=Rt(t,n),c=t.v[n%t._length],l=t.v[n===0?t._length-1:n-1],u=t.v[(n+1)%t._length],d=a===2?Math.sqrt((c[0]-l[0])**2+(c[1]-l[1])**2):0,f=a===2?Math.sqrt((c[0]-u[0])**2+(c[1]-u[1])**2):0;It(e,t.v[n%t._length],s,o,r,f/((i+1)*2),d/((i+1)*2),a)}function Bt(e,t,n,r,i,a){for(var o=0;o1&&t.length>1&&(i=Wt(e[0],t[t.length-1]),i)?[[e[0].split(i[0])[0]],[t[t.length-1].split(i[1])[1]]]:[n,r]}function Kt(e){for(var t,n=1;n1&&(t=Gt(e[e.length-1],e[0]),e[e.length-1]=t[0],e[0]=t[1]),e}function qt(e,t){var n=e.inflectionPoints(),r,i,a,o;if(n.length===0)return[Ht(e,t)];if(n.length===1||vt(n[1],1))return a=e.split(n[0]),r=a[0],i=a[1],[Ht(r,t),Ht(i,t)];a=e.split(n[0]),r=a[0];var s=(n[1]-n[0])/(1-n[0]);return a=a[1].split(s),o=a[0],i=a[1],[Ht(r,t),Ht(o,t),Ht(i,t)]}function Jt(){}u([ft],Jt),Jt.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amount=W.getProp(e,t.a,0,null,this),this.miterLimit=W.getProp(e,t.ml,0,null,this),this.lineJoin=t.lj,this._isAnimated=this.amount.effectsSequence.length!==0},Jt.prototype.processPath=function(e,t,n,r){var i=We.newElement();i.c=e.c;var a=e.length();e.c||--a;var o,s,c,l=[];for(o=0;o=0;--o)c=Tt.shapeSegmentInverted(e,o),l.push(qt(c,t));l=Kt(l);var u=null,d=null;for(o=0;o0&&(o=!1),o){var u=l(`style`);u.setAttribute(`f-forigin`,n[r].fOrigin),u.setAttribute(`f-origin`,n[r].origin),u.setAttribute(`f-family`,n[r].fFamily),u.type=`text/css`,u.innerText=`@font-face {font-family: `+n[r].fFamily+`; font-style: normal; src: url('`+n[r].fPath+`');}`,t.appendChild(u)}}else if(n[r].fOrigin===`g`||n[r].origin===1){for(s=document.querySelectorAll(`link[f-forigin="g"], link[f-origin="1"]`),c=0;c=55296&&n<=56319){var r=e.charCodeAt(1);r>=56320&&r<=57343&&(t=(n-55296)*1024+r-56320+65536)}return t}function S(e,t){var n=e.toString(16)+t.toString(16);return d.indexOf(n)!==-1}function C(e){return e===s}function w(e){return e===o}function T(e){var t=x(e);return t>=c&&t<=u}function E(e){return T(e.substr(0,2))&&T(e.substr(2,2))}function D(e){return t.indexOf(e)!==-1}function O(e,t){var o=x(e.substr(t,2));if(o!==n)return!1;var s=0;for(t+=2;s<5;){if(o=x(e.substr(t,2)),oa)return!1;s+=1,t+=2}return x(e.substr(t,2))===r}function k(){this.isLoaded=!0}var A=function(){this.fonts=[],this.chars=null,this.typekitLoaded=0,this.isLoaded=!1,this._warned=!1,this.initTime=Date.now(),this.setIsLoadedBinded=this.setIsLoaded.bind(this),this.checkLoadedFontsBinded=this.checkLoadedFonts.bind(this)};return A.isModifier=S,A.isZeroWidthJoiner=C,A.isFlagEmoji=E,A.isRegionalCode=T,A.isCombinedCharacter=D,A.isRegionalFlag=O,A.isVariationSelector=w,A.BLACK_FLAG_CODE_POINT=n,A.prototype={addChars:_,addFonts:g,getCharData:v,getFontByName:b,measureText:y,checkLoadedFonts:m,setIsLoaded:k},A}();function Zt(e){this.animationData=e}Zt.prototype.getProp=function(e){return this.animationData.slots&&this.animationData.slots[e.sid]?Object.assign(e,this.animationData.slots[e.sid].p):e};function Qt(e){return new Zt(e)}function $t(){}$t.prototype={initRenderable:function(){this.isInRange=!1,this.hidden=!1,this.isTransparent=!1,this.renderableComponents=[]},addRenderableComponent:function(e){this.renderableComponents.indexOf(e)===-1&&this.renderableComponents.push(e)},removeRenderableComponent:function(e){this.renderableComponents.indexOf(e)!==-1&&this.renderableComponents.splice(this.renderableComponents.indexOf(e),1)},prepareRenderableFrame:function(e){this.checkLayerLimits(e)},checkTransparency:function(){this.finalTransform.mProp.o.v<=0?!this.isTransparent&&this.globalData.renderConfig.hideOnTransparent&&(this.isTransparent=!0,this.hide()):this.isTransparent&&(this.isTransparent=!1,this.show())},checkLayerLimits:function(e){this.data.ip-this.data.st<=e&&this.data.op-this.data.st>e?this.isInRange!==!0&&(this.globalData._mdf=!0,this._mdf=!0,this.isInRange=!0,this.show()):this.isInRange!==!1&&(this.globalData._mdf=!0,this.isInRange=!1,this.hide())},renderRenderable:function(){var e,t=this.renderableComponents.length;for(e=0;e.1)&&this.audio.seek(this._currentTime/this.globalData.frameRate):(this.audio.play(),this.audio.seek(this._currentTime/this.globalData.frameRate),this._isPlaying=!0))},hn.prototype.show=function(){},hn.prototype.hide=function(){this.audio.pause(),this._isPlaying=!1},hn.prototype.pause=function(){this.audio.pause(),this._isPlaying=!1,this._canPlay=!1},hn.prototype.resume=function(){this._canPlay=!0},hn.prototype.setRate=function(e){this.audio.rate(e)},hn.prototype.volume=function(e){this._volumeMultiplier=e,this._previousVolume=e*this._volume,this.audio.volume(this._previousVolume)},hn.prototype.getBaseElement=function(){return null},hn.prototype.destroy=function(){},hn.prototype.sourceRectAtTime=function(){},hn.prototype.initExpressions=function(){};function gn(){}gn.prototype.checkLayers=function(e){var t,n=this.layers.length,r;for(this.completeLayers=!0,t=n-1;t>=0;--t)this.elements[t]||(r=this.layers[t],r.ip-r.st<=e-this.layers[t].st&&r.op-r.st>e-this.layers[t].st&&this.buildItem(t)),this.completeLayers=this.elements[t]?this.completeLayers:!1;this.checkPendingElements()},gn.prototype.createItem=function(e){switch(e.ty){case 2:return this.createImage(e);case 0:return this.createComp(e);case 1:return this.createSolid(e);case 3:return this.createNull(e);case 4:return this.createShape(e);case 5:return this.createText(e);case 6:return this.createAudio(e);case 13:return this.createCamera(e);case 15:return this.createFootage(e);default:return this.createNull(e)}},gn.prototype.createCamera=function(){throw Error(`You're using a 3d camera. Try the html renderer.`)},gn.prototype.createAudio=function(e){return new hn(e,this.globalData,this)},gn.prototype.createFootage=function(e){return new mn(e,this.globalData,this)},gn.prototype.buildAllItems=function(){var e,t=this.layers.length;for(e=0;e0&&(this.maskElement.setAttribute(`id`,p),this.element.maskedElement.setAttribute(b,`url(`+c()+`#`+p+`)`),r.appendChild(this.maskElement)),this.viewData.length&&this.element.addRenderableComponent(this)}yn.prototype.getMaskProperty=function(e){return this.viewData[e].prop},yn.prototype.renderFrame=function(e){var t=this.element.finalTransform.mat,n,r=this.masksProperties.length;for(n=0;n1&&(r+=` C`+t.o[i-1][0]+`,`+t.o[i-1][1]+` `+t.i[0][0]+`,`+t.i[0][1]+` `+t.v[0][0]+`,`+t.v[0][1]),n.lastPath!==r){var o=``;n.elem&&(t.c&&(o=e.inv?this.solidPath+r:r),n.elem.setAttribute(`d`,o)),n.lastPath=r}},yn.prototype.destroy=function(){this.element=null,this.globalData=null,this.maskElement=null,this.data=null,this.masksProperties=null};var bn=function(){var e={};e.createFilter=t,e.createAlphaToLuminanceFilter=n;function t(e,t){var n=z(`filter`);return n.setAttribute(`id`,e),t!==!0&&(n.setAttribute(`filterUnits`,`objectBoundingBox`),n.setAttribute(`x`,`0%`),n.setAttribute(`y`,`0%`),n.setAttribute(`width`,`100%`),n.setAttribute(`height`,`100%`)),n}function n(){var e=z(`feColorMatrix`);return e.setAttribute(`type`,`matrix`),e.setAttribute(`color-interpolation-filters`,`sRGB`),e.setAttribute(`values`,`0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1`),e}return e}(),xn=function(){var e={maskType:!0,svgLumaHidden:!0,offscreenCanvas:typeof OffscreenCanvas<`u`};return(/MSIE 10/i.test(navigator.userAgent)||/MSIE 9/i.test(navigator.userAgent)||/rv:11.0/i.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent))&&(e.maskType=!1),/firefox/i.test(navigator.userAgent)&&(e.svgLumaHidden=!1),e}(),Sn={},Cn=`filter_result_`;function wn(e){var t,n=`SourceGraphic`,r=e.data.ef?e.data.ef.length:0,i=I(),a=bn.createFilter(i,!0),o=0;this.filters=[];var s;for(t=0;t=0&&(n=this.shapeModifiers[e].processShapes(this._isFirstFrame),!n);--e);}},searchProcessedElement:function(e){for(var t=this.processedElements,n=0,r=t.length;n.01)return!1;n+=1}return!0},zn.prototype.checkCollapsable=function(){if(this.o.length/2!=this.c.length/4)return!1;if(this.data.k.k[0].s)for(var e=0,t=this.data.k.k.length;e0;)c=r.transformers[g].mProps._mdf||c,--h,--g;if(c)for(h=f-r.styles[u].lvl,g=r.transformers.length-1;h>0;)m.multiply(r.transformers[g].mProps.v),--h,--g}else m=e;if(p=r.sh.paths,o=p._length,c){for(s=``,a=0;a=1?v=.99:v<=-1&&(v=-.99);var y=g*v,b=Math.cos(_+t.a.v)*y+a[0],x=Math.sin(_+t.a.v)*y+a[1];r.setAttribute(`fx`,b),r.setAttribute(`fy`,x),i&&!t.g._collapsable&&(t.of.setAttribute(`fx`,b),t.of.setAttribute(`fy`,x))}}}function u(e,t,n){var r=t.style,i=t.d;i&&(i._mdf||n)&&i.dashStr&&(r.pElem.setAttribute(`stroke-dasharray`,i.dashStr),r.pElem.setAttribute(`stroke-dashoffset`,i.dashoffset[0])),t.c&&(t.c._mdf||n)&&r.pElem.setAttribute(`stroke`,`rgb(`+C(t.c.v[0])+`,`+C(t.c.v[1])+`,`+C(t.c.v[2])+`)`),(t.o._mdf||n)&&r.pElem.setAttribute(`stroke-opacity`,t.o.v),(t.w._mdf||n)&&(r.pElem.setAttribute(`stroke-width`,t.w.v),r.msElem&&r.msElem.setAttribute(`stroke-width`,t.w.v))}return n}();function X(e,t,n){this.shapes=[],this.shapesData=e.shapes,this.stylesList=[],this.shapeModifiers=[],this.itemsData=[],this.processedElements=[],this.animatedContents=[],this.initElement(e,t,n),this.prevViewData=[]}u([fn,vn,Tn,An,En,pn,Dn],X),X.prototype.initSecondaryElement=function(){},X.prototype.identityMatrix=new K,X.prototype.buildExpressionInterface=function(){},X.prototype.createContent=function(){this.searchShapes(this.shapesData,this.itemsData,this.prevViewData,this.layerElement,0,[],!0),this.filterUniqueShapes()},X.prototype.filterUniqueShapes=function(){var e,t=this.shapes.length,n,r,i=this.stylesList.length,a,o=[],s=!1;for(r=0;r1&&s&&this.setShapesAsAnimated(o)}},X.prototype.setShapesAsAnimated=function(e){var t,n=e.length;for(t=0;t=0;--c){if(g=this.searchProcessedElement(e[c]),g?t[c]=n[g-1]:e[c]._render=o,e[c].ty===`fl`||e[c].ty===`st`||e[c].ty===`gf`||e[c].ty===`gs`||e[c].ty===`no`)g?t[c].style.closed=e[c].hd:t[c]=this.createStyleElement(e[c],i),e[c]._render&&t[c].style.pElem.parentNode!==r&&r.appendChild(t[c].style.pElem),f.push(t[c].style);else if(e[c].ty===`gr`){if(!g)t[c]=this.createGroupElement(e[c]);else for(d=t[c].it.length,u=0;u1,this.kf&&this.addEffect(this.getKeyframeValue.bind(this)),this.kf},qn.prototype.addEffect=function(e){this.effectsSequence.push(e),this.elem.addDynamicProperty(this)},qn.prototype.getValue=function(e){if(!((this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)&&!e)){this.currentData.t=this.data.d.k[this.keysIndex].s.t;var t=this.currentData,n=this.keysIndex;if(this.lock){this.setCurrentData(this.currentData);return}this.lock=!0,this._mdf=!1;var r,i=this.effectsSequence.length,a=e||this.data.d.k[this.keysIndex].s;for(r=0;rt);)n+=1;return this.keysIndex!==n&&(this.keysIndex=n),this.data.d.k[this.keysIndex].s},qn.prototype.buildFinalText=function(e){for(var t=[],n=0,r=e.length,i,a,o=!1,s=!1,c=``;n=55296&&i<=56319?Xt.isRegionalFlag(e,n)?c=e.substr(n,14):(a=e.charCodeAt(n+1),a>=56320&&a<=57343&&(Xt.isModifier(i,a)?(c=e.substr(n,2),o=!0):c=Xt.isFlagEmoji(e.substr(n,4))?e.substr(n,4):e.substr(n,2))):i>56319?(a=e.charCodeAt(n+1),Xt.isVariationSelector(i)&&(o=!0)):Xt.isZeroWidthJoiner(i)&&(o=!0,s=!0),o?(t[t.length-1]+=c,o=!1):t.push(c),n+=c.length;return t},qn.prototype.completeTextData=function(e){e.__complete=!0;var t=this.elem.globalData.fontManager,n=this.data,r=[],i,a,o,s=0,c,l=n.m.g,u=0,d=0,f=0,p=[],m=0,h=0,g,_,v=t.getFontByName(e.f),y,b=0,x=Yt(v);e.fWeight=x.weight,e.fStyle=x.style,e.finalSize=e.s,e.finalText=this.buildFinalText(e.t),a=e.finalText.length,e.finalLineHeight=e.lh;var S=e.tr/1e3*e.finalSize,C;if(e.sz)for(var w=!0,T=e.sz[0],E=e.sz[1],D,O;w;){O=this.buildFinalText(e.t),D=0,m=0,a=O.length,S=e.tr/1e3*e.finalSize;var k=-1;for(i=0;iT&&O[i]!==` `?(k===-1?a+=1:i=k,D+=e.finalLineHeight||e.finalSize*1.2,O.splice(i,+(k===i),`\r`),k=-1,m=0):(m+=b,m+=S);D+=v.ascent*e.finalSize/100,this.canResize&&e.finalSize>this.minimumFontSize&&Eh?m:h,m=-2*S,c=``,o=!0,f+=1):c=j,t.chars?(y=t.getCharData(j,v.fStyle,t.getFontByName(e.f).fFamily),b=o?0:y.w*e.finalSize/100):b=t.measureText(c,e.f,e.finalSize),j===` `?A+=b+S:(m+=b+S+A,A=0),r.push({l:b,an:b,add:u,n:o,anIndexes:[],val:c,line:f,animatorJustifyOffset:0}),l==2){if(u+=b,c===``||c===` `||i===a-1){for((c===``||c===` `)&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;s+=1,u=0}}else if(l==3){if(u+=b,c===``||i===a-1){for(c===``&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;u=0,s+=1}}else r[s].ind=s,r[s].extra=0,s+=1;if(e.l=r,h=m>h?m:h,p.push(m),e.sz)e.boxWidth=e.sz[0],e.justifyOffset=0;else switch(e.boxWidth=h,e.j){case 1:e.justifyOffset=-e.boxWidth;break;case 2:e.justifyOffset=-e.boxWidth/2;break;default:e.justifyOffset=0}e.lineWidths=p;var M=n.a,N,P;_=M.length;var F,I,L=[];for(g=0;g<_;g+=1){for(N=M[g],N.a.sc&&(e.strokeColorAnim=!0),N.a.sw&&(e.strokeWidthAnim=!0),(N.a.fc||N.a.fh||N.a.fs||N.a.fb)&&(e.fillColorAnim=!0),I=0,F=N.s.b,i=0;i0?i=this.ne.v/100:a=-this.ne.v/100,this.xe.v>0?o=1-this.xe.v/100:s=1+this.xe.v/100;var c=be.getBezierEasing(i,a,o,s).get,l=0,u=this.finalS,d=this.finalE,f=this.data.sh;if(f===2)l=d===u?+(r>=d):e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===3)l=d===u?r>=d?0:1:1-e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===4)d===u?l=0:(l=e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l<.5?l*=2:l=1-2*(l-.5)),l=c(l);else if(f===5){if(d===u)l=0;else{var p=d-u;r=t(e(0,r+.5-u),d-u);var m=-p/2+r,h=p/2;l=Math.sqrt(1-m*m/(h*h))}l=c(l)}else f===6?(d===u?l=0:(r=t(e(0,r+.5-u),d-u),l=(1+Math.cos(Math.PI+Math.PI*2*r/(d-u)))/2),l=c(l)):(r>=n(u)&&(l=r-u<0?e(0,t(t(d,1)-(u-r),1)):e(0,t(d-r,1))),l=c(l));if(this.sm.v!==100){var g=this.sm.v*.01;g===0&&(g=1e-8);var _=.5-g*.5;l<_?l=0:(l=(l-_)/g,l>1&&(l=1))}return l*this.a.v},getValue:function(e){this.iterateDynamicProperties(),this._mdf=e||this._mdf,this._currentTextLength=this.elem.textProperty.currentData.l.length||0,e&&this.data.r===2&&(this.e.v=this._currentTextLength);var t=this.data.r===2?1:100/this.data.totalChars,n=this.o.v/t,r=this.s.v/t+n,i=this.e.v/t+n;if(r>i){var a=r;r=i,i=a}this.finalS=r,this.finalE=i}},u([Ve],r);function i(e,t,n){return new r(e,t,n)}return{getTextSelectorProp:i}}();function Yn(e,t,n){var r={propType:!1},i=W.getProp,a=t.a;this.a={r:a.r?i(e,a.r,0,D,n):r,rx:a.rx?i(e,a.rx,0,D,n):r,ry:a.ry?i(e,a.ry,0,D,n):r,sk:a.sk?i(e,a.sk,0,D,n):r,sa:a.sa?i(e,a.sa,0,D,n):r,s:a.s?i(e,a.s,1,.01,n):r,a:a.a?i(e,a.a,1,0,n):r,o:a.o?i(e,a.o,0,.01,n):r,p:a.p?i(e,a.p,1,0,n):r,sw:a.sw?i(e,a.sw,0,0,n):r,sc:a.sc?i(e,a.sc,1,0,n):r,fc:a.fc?i(e,a.fc,1,0,n):r,fh:a.fh?i(e,a.fh,0,0,n):r,fs:a.fs?i(e,a.fs,0,.01,n):r,fb:a.fb?i(e,a.fb,0,.01,n):r,t:a.t?i(e,a.t,0,0,n):r},this.s=Jn.getTextSelectorProp(e,t.s,n),this.s.t=t.s.t}function Xn(e,t,n){this._isFirstFrame=!0,this._hasMaskedPath=!1,this._frameId=-1,this._textData=e,this._renderType=t,this._elem=n,this._animatorsData=m(this._textData.a.length),this._pathData={},this._moreOptions={alignment:{}},this.renderedLetters=[],this.lettersChangedFlag=!1,this.initDynamicPropertyContainer(n)}Xn.prototype.searchProperties=function(){var e,t=this._textData.a.length,n,r=W.getProp;for(e=0;e=m+xe||!x?(T=(m+xe-g)/h.partialLength,ie=b.point[0]+(h.point[0]-b.point[0])*T,ae=b.point[1]+(h.point[1]-b.point[1])*T,a.translate(-n[0]*f[u].an*.005,-(n[1]*A)*.01),_=!1):x&&(g+=h.partialLength,v+=1,v>=x.length&&(v=0,y+=1,S[y]?x=S[y].points:D.v.c?(v=0,y=0,x=S[y].points):(g-=h.partialLength,x=null)),x&&(b=h,h=x[v],C=h.partialLength));re=f[u].an/2-f[u].add,a.translate(-re,0,0)}else re=f[u].an/2-f[u].add,a.translate(-re,0,0),a.translate(-n[0]*f[u].an*.005,-n[1]*A*.01,0);for(P=0;Pe?this.textSpans[e].span:z(s?`g`:`text`),b<=e){if(c.setAttribute(`stroke-linecap`,`butt`),c.setAttribute(`stroke-linejoin`,`round`),c.setAttribute(`stroke-miterlimit`,`4`),this.textSpans[e].span=c,s){var S=z(`g`);c.appendChild(S),this.textSpans[e].childSpan=S}this.textSpans[e].span=c,this.layerElement.appendChild(c)}c.style.display=`inherit`}if(l.reset(),d&&(o[e].n&&(f=-g,p+=n.yOffset,p+=+!!h,h=!1),this.applyTextPropertiesToMatrix(n,l,o[e].line,f,p),f+=o[e].l||0,f+=g),s){x=this.globalData.fontManager.getCharData(n.finalText[e],r.fStyle,this.globalData.fontManager.getFontByName(n.f).fFamily);var C;if(x.t===1)C=new ir(x.data,this.globalData,this);else{var w=Qn;x.data&&x.data.shapes&&(w=this.buildShapeData(x.data,n.finalSize)),C=new X(w,this.globalData,this)}if(this.textSpans[e].glyph){var T=this.textSpans[e].glyph;this.textSpans[e].childSpan.removeChild(T.layerElement),T.destroy()}this.textSpans[e].glyph=C,C._debug=!0,C.prepareFrame(0),C.renderFrame(),this.textSpans[e].childSpan.appendChild(C.layerElement),x.t===1&&this.textSpans[e].childSpan.setAttribute(`transform`,`scale(`+n.finalSize/100+`,`+n.finalSize/100+`)`)}else d&&c.setAttribute(`transform`,`translate(`+l.props[12]+`,`+l.props[13]+`)`),c.textContent=o[e].val,c.setAttributeNS(`http://www.w3.org/XML/1998/namespace`,`xml:space`,`preserve`)}d&&c&&c.setAttribute(`d`,u)}for(;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].prepareFrame(e-this.layers[t].st);if(this.globalData._mdf)for(t=0;t=0;--n)(this.completeLayers||this.elements[n])&&(this.elements[n].prepareFrame(this.renderedFrame-this.layers[n].st),this.elements[n]._mdf&&(this._mdf=!0))}},rr.prototype.renderInnerContent=function(){var e,t=this.layers.length;for(e=0;e=0;--n)e.finalTransform.multiply(e.transforms[n].transform.mProps.v);e._mdf=i},processSequences:function(e){var t,n=this.sequenceList.length;for(t=0;t=1){this.buffers=[];var e=this.globalData.canvasContext,t=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(t);var n=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(n),this.data.tt>=3&&!document._isProxy&&lr.loadLumaCanvas()}this.canvasContext=this.globalData.canvasContext,this.transformCanvas=this.globalData.transformCanvas,this.renderableEffectsManager=new dr(this),this.searchEffectTransforms()},createContent:function(){},setBlendMode:function(){var e=this.globalData;if(e.blendMode!==this.data.bm){e.blendMode=this.data.bm;var t=en(this.data.bm);e.canvasContext.globalCompositeOperation=t}},createRenderableComponents:function(){this.maskManager=new fr(this.data,this),this.transformEffects=this.renderableEffectsManager.getEffects(_n.TRANSFORM_EFFECT)},hideElement:function(){!this.hidden&&(!this.isInRange||this.isTransparent)&&(this.hidden=!0)},showElement:function(){this.isInRange&&!this.isTransparent&&(this.hidden=!1,this._isFirstFrame=!0,this.maskManager._isFirstFrame=!0)},clearCanvas:function(e){e.clearRect(this.transformCanvas.tx,this.transformCanvas.ty,this.transformCanvas.w*this.transformCanvas.sx,this.transformCanvas.h*this.transformCanvas.sy)},prepareLayer:function(){if(this.data.tt>=1){var e=this.buffers[0].getContext(`2d`);this.clearCanvas(e),e.drawImage(this.canvasContext.canvas,0,0),this.currentTransform=this.canvasContext.getTransform(),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform)}},exitLayer:function(){if(this.data.tt>=1){var e=this.buffers[1],t=e.getContext(`2d`);if(this.clearCanvas(t),t.drawImage(this.canvasContext.canvas,0,0),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform),this.comp.getElementById(`tp`in this.data?this.data.tp:this.data.ind-1).renderFrame(!0),this.canvasContext.setTransform(1,0,0,1,0,0),this.data.tt>=3&&!document._isProxy){var n=lr.getLumaCanvas(this.canvasContext.canvas);n.getContext(`2d`).drawImage(this.canvasContext.canvas,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.drawImage(n,0,0)}this.canvasContext.globalCompositeOperation=mr[this.data.tt],this.canvasContext.drawImage(e,0,0),this.canvasContext.globalCompositeOperation=`destination-over`,this.canvasContext.drawImage(this.buffers[0],0,0),this.canvasContext.setTransform(this.currentTransform),this.canvasContext.globalCompositeOperation=`source-over`}},renderFrame:function(e){if(!(this.hidden||this.data.hd)&&!(this.data.td===1&&!e)){this.renderTransform(),this.renderRenderable(),this.renderLocalTransform(),this.setBlendMode();var t=this.data.ty===0;this.prepareLayer(),this.globalData.renderer.save(t),this.globalData.renderer.ctxTransform(this.finalTransform.localMat.props),this.globalData.renderer.ctxOpacity(this.finalTransform.localOpacity),this.renderInnerContent(),this.globalData.renderer.restore(t),this.exitLayer(),this.maskManager.hasMasks&&this.globalData.renderer.restore(!0),this._isFirstFrame&&=!1}},destroy:function(){this.canvasContext=null,this.data=null,this.globalData=null,this.maskManager.destroy()},mHelper:new K},pr.prototype.hide=pr.prototype.hideElement,pr.prototype.show=pr.prototype.showElement;function hr(e,t,n,r){this.styledShapes=[],this.tr=[0,0,0,0,0,0];var i=4;t.ty===`rc`?i=5:t.ty===`el`?i=6:t.ty===`sr`&&(i=7),this.sh=G.getShapeProp(e,t,i,e);var a,o=n.length,s;for(a=0;a=0;--a){if(d=this.searchProcessedElement(e[a]),d?t[a]=n[d-1]:e[a]._shouldRender=r,e[a].ty===`fl`||e[a].ty===`st`||e[a].ty===`gf`||e[a].ty===`gs`)d?t[a].style.closed=!1:t[a]=this.createStyleElement(e[a],m),l.push(t[a].style);else if(e[a].ty===`gr`){if(!d)t[a]=this.createGroupElement(e[a]);else for(c=t[a].it.length,s=0;s=0;--i)t[i].ty===`tr`?(o=n[i].transform,this.renderShapeTransform(e,o)):t[i].ty===`sh`||t[i].ty===`el`||t[i].ty===`rc`||t[i].ty===`sr`?this.renderPath(t[i],n[i]):t[i].ty===`fl`?this.renderFill(t[i],n[i],o):t[i].ty===`st`?this.renderStroke(t[i],n[i],o):t[i].ty===`gf`||t[i].ty===`gs`?this.renderGradientFill(t[i],n[i],o):t[i].ty===`gr`?this.renderShape(o,t[i].it,n[i].it):t[i].ty;r&&this.drawLayer()},gr.prototype.renderStyledShape=function(e,t){if(this._isFirstFrame||t._mdf||e.transforms._mdf){var n=e.trNodes,r=t.paths,i,a,o,s=r._length;n.length=0;var c=e.transforms.finalTransform;for(o=0;o=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;ma&&c===`xMidYMid slice`||ii&&s===`meet`||ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))/2*this.renderConfig.dpr:l===`xMax`&&(ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))*this.renderConfig.dpr:this.transformCanvas.tx=0,u===`YMid`&&(a>i&&s===`meet`||ai&&s===`meet`||a=0;--e)this.elements[e]&&this.elements[e].destroy&&this.elements[e].destroy();this.elements.length=0,this.globalData.canvasContext=null,this.animationItem.container=null,this.destroyed=!0},Z.prototype.renderFrame=function(e,t){if(!(this.renderedFrame===e&&this.renderConfig.clearCanvas===!0&&!t||this.destroyed||e===-1)){this.renderedFrame=e,this.globalData.frameNum=e-this.animationItem._isFirstFrame,this.globalData.frameId+=1,this.globalData._mdf=!this.renderConfig.clearCanvas||t,this.globalData.projectInterface.currentFrame=e;var n,r=this.layers.length;for(this.completeLayers||this.checkLayers(e),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].prepareFrame(e-this.layers[n].st);if(this.globalData._mdf){for(this.renderConfig.clearCanvas===!0?this.canvasContext.clearRect(0,0,this.transformCanvas.w,this.transformCanvas.h):this.save(),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].renderFrame();this.renderConfig.clearCanvas!==!0&&this.restore()}}},Z.prototype.buildItem=function(e){var t=this.elements;if(!(t[e]||this.layers[e].ty===99)){var n=this.createItem(this.layers[e],this,this.globalData);t[e]=n,n.initExpressions()}},Z.prototype.checkPendingElements=function(){for(;this.pendingElements.length;)this.pendingElements.pop().checkParenting()},Z.prototype.hide=function(){this.animationItem.container.style.display=`none`},Z.prototype.show=function(){this.animationItem.container.style.display=`block`};function br(){this.opacity=-1,this.transform=p(`float32`,16),this.fillStyle=``,this.strokeStyle=``,this.lineWidth=``,this.lineCap=``,this.lineJoin=``,this.miterLimit=``,this.id=Math.random()}function xr(){this.stack=[],this.cArrPos=0,this.cTr=new K;var e,t=15;for(e=0;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].renderFrame()},Sr.prototype.destroy=function(){var e;for(e=this.layers.length-1;e>=0;--e)this.elements[e]&&this.elements[e].destroy();this.layers=null,this.elements=null},Sr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)};function Cr(e,t){this.animationItem=e,this.renderConfig={clearCanvas:t&&t.clearCanvas!==void 0?t.clearCanvas:!0,context:t&&t.context||null,progressiveLoad:t&&t.progressiveLoad||!1,preserveAspectRatio:t&&t.preserveAspectRatio||`xMidYMid meet`,imagePreserveAspectRatio:t&&t.imagePreserveAspectRatio||`xMidYMid slice`,contentVisibility:t&&t.contentVisibility||`visible`,className:t&&t.className||``,id:t&&t.id||``,runExpressions:!t||t.runExpressions===void 0||t.runExpressions},this.renderConfig.dpr=t&&t.dpr||1,this.animationItem.wrapper&&(this.renderConfig.dpr=t&&t.dpr||window.devicePixelRatio||1),this.renderedFrame=-1,this.globalData={frameNum:-1,_mdf:!1,renderConfig:this.renderConfig,currentGlobalAlpha:-1},this.contextData=new xr,this.elements=[],this.pendingElements=[],this.transformMat=new K,this.completeLayers=!1,this.rendererType=`canvas`,this.renderConfig.clearCanvas&&(this.ctxTransform=this.contextData.transform.bind(this.contextData),this.ctxOpacity=this.contextData.opacity.bind(this.contextData),this.ctxFillStyle=this.contextData.fillStyle.bind(this.contextData),this.ctxStrokeStyle=this.contextData.strokeStyle.bind(this.contextData),this.ctxLineWidth=this.contextData.lineWidth.bind(this.contextData),this.ctxLineCap=this.contextData.lineCap.bind(this.contextData),this.ctxLineJoin=this.contextData.lineJoin.bind(this.contextData),this.ctxMiterLimit=this.contextData.miterLimit.bind(this.contextData),this.ctxFill=this.contextData.fill.bind(this.contextData),this.ctxFillRect=this.contextData.fillRect.bind(this.contextData),this.ctxStroke=this.contextData.stroke.bind(this.contextData),this.save=this.contextData.save.bind(this.contextData))}return u([Z],Cr),Cr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)},ge(`canvas`,Cr),dt.registerModifier(`tm`,pt),dt.registerModifier(`pb`,mt),dt.registerModifier(`rp`,gt),dt.registerModifier(`rd`,_t),dt.registerModifier(`zz`,Ft),dt.registerModifier(`op`,Jt),J}))}))(),1),_t=0,vt=e=>`${e}-${++_t}`,yt=e=>({key:vt(e),name:``,rarity:`1000`,sortOrder:`0`,file:null,animation:null,fileError:``}),bt=()=>({key:vt(`backdrop`),name:``,backdropID:`1`,rarity:`1000`,sortOrder:`0`,center:`#6f5bea`,edge:`#34278f`,pattern:`#a89df5`,text:`#ffffff`});function xt({data:e,compact:t=!1}){let n=(0,g.useRef)(null);return(0,g.useEffect)(()=>{if(!n.current)return;let t=gt.default.loadAnimation({container:n.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)});return()=>t.destroy()},[e]),(0,H.jsx)(`div`,{className:`collectible-animation ${t?`compact`:``}`,ref:n})}function St({giftID:e,attribute:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n=!1;return a(!1),x.giftCollectibleAnimation(e,t.kind,t.id).then(e=>{n||r(e)}).catch(()=>{n||a(!0)}),()=>{n=!0}},[e,t.id,t.kind]),i?(0,H.jsx)(`div`,{className:`collectible-animation compact failed`,children:`!`}):n?(0,H.jsx)(xt,{data:n,compact:!0}):(0,H.jsx)(`div`,{className:`collectible-animation compact loading`,children:(0,H.jsx)(A,{className:`spin`,size:15})})}async function Ct(e){let t=new Uint8Array(await e.arrayBuffer()),n=t;if(t.length>=2&&t[0]===31&&t[1]===139){if(!(`DecompressionStream`in window))throw Error(`This browser cannot preview TGS files`);let e=new Blob([t]).stream().pipeThrough(new DecompressionStream(`gzip`));n=new Uint8Array(await new Response(e).arrayBuffer())}let r=JSON.parse(new TextDecoder().decode(n));if(!r||typeof r!=`object`||Array.isArray(r))throw Error(`Invalid Lottie JSON`);return r}var wt=e=>Number.parseInt(e.replace(`#`,``),16),Tt=e=>e.rarity_kind===`permille`?`${e.rarity_permille}‰`:e.rarity_kind;function Et({gift:e,onClose:t,onPublished:n}){let{t:r}=Ce(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(!0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(`100`),[_,v]=(0,g.useState)(`1000`),[y,S]=(0,g.useState)(`gift-${e.GiftID}`),[C,w]=(0,g.useState)(``),[T,E]=(0,g.useState)([yt(`model`)]),[D,O]=(0,g.useState)([yt(`pattern`)]),[M,N]=(0,g.useState)([bt()]);(0,g.useEffect)(()=>{let t=!1;return x.giftCollectibles(e.GiftID).then(n=>{t||(a(n),n.found&&(h(String(n.upgrade_stars??100)),v(String(n.supply_total??1e3)),S(n.slug_prefix??`gift-${e.GiftID}`)))}).catch(e=>d(b(e))).finally(()=>{t||s(!1)}),()=>{t=!0}},[e.GiftID]);let P=(0,g.useMemo)(()=>({models:T.reduce((e,t)=>e+Number(t.rarity||0),0),patterns:D.reduce((e,t)=>e+Number(t.rarity||0),0),backdrops:M.reduce((e,t)=>e+Number(t.rarity||0),0)}),[T,D,M]),F=()=>p(null),I=(e,t,n)=>{(e===`models`?E:O)(e=>e.map(e=>e.key===t?{...e,...n}:e)),F()};async function L(e,t,n){if(I(e,t.key,{file:n,animation:null,fileError:``}),n)try{let r=await Ct(n);I(e,t.key,{animation:r,fileError:``})}catch(n){I(e,t.key,{animation:null,fileError:b(n)})}}function ee(e,t=``){if(!C.trim())throw Error(r(`action.reasonRequired`));for(let e of[...T,...D])if(!e.file)throw Error(r(`collectibles.fileRequired`));let n=new FormData,i=e=>e.map(e=>({name:e.name.trim(),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),file_key:e.key}));n.set(`metadata`,JSON.stringify({command_id:t,reason:C.trim(),confirm:e,upgrade_stars:m,supply_total:Number(_),slug_prefix:y.trim().toLowerCase(),models:i(T),patterns:i(D),backdrops:M.map(e=>({name:e.name.trim(),backdrop_id:Number(e.backdropID),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),center_color:wt(e.center),edge_color:wt(e.edge),pattern_color:wt(e.pattern),text_color:wt(e.text)}))}));for(let e of[...T,...D])n.set(e.key,e.file,e.file.name);return n}async function te(){l(!0),d(``),p(null);try{p(await x.publishGiftCollectibles(e.GiftID,ee(!1)))}catch(e){d(b(e))}finally{l(!1)}}async function re(){if(f){l(!0),d(``);try{await x.publishGiftCollectibles(e.GiftID,ee(!0,f.command_id)),n(),t()}catch(e){d(b(e))}finally{l(!1)}}}let ie=(e,t,n)=>(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.${e}`)}),(0,H.jsx)(`span`,{children:r(`collectibles.rarityHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(G,{tone:P[e]>0?`good`:`neutral`,children:[P[e],`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{n([...t,yt(e===`models`?`model`:`pattern`)]),F()},children:[(0,H.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:t.map((i,a)=>(0,H.jsxs)(`div`,{className:`collectible-row animated`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:a+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:i.name,maxLength:128,onChange:t=>I(e,i.key,{name:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:i.rarity,onChange:t=>I(e,i.key,{rarity:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:i.sortOrder,onChange:t=>I(e,i.key,{sortOrder:t.target.value})})]}),(0,H.jsxs)(`label`,{className:`collectible-file`,children:[(0,H.jsx)(`span`,{children:r(`gifts.animation`)}),(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:t=>void L(e,i,t.target.files?.[0]??null)}),(0,H.jsxs)(`em`,{children:[(0,H.jsx)(R,{size:13}),i.file?.name??r(`gifts.chooseFile`)]})]}),(0,H.jsx)(`div`,{className:`collectible-inline-preview`,children:i.animation?(0,H.jsx)(xt,{data:i.animation,compact:!0}):(0,H.jsx)(j,{size:16})}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:t.length===1,onClick:()=>{n(t.filter(e=>e.key!==i.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(he,{size:14})}),i.fileError&&(0,H.jsx)(`span`,{className:`collectible-file-error`,children:i.fileError})]},i.key))})]});return(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal collectible-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(`collectibles.title`,{id:e.GiftID}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:r(`collectibles.eyebrow`)}),(0,H.jsx)(`h2`,{children:r(`collectibles.title`,{id:e.GiftID})}),(0,H.jsx)(`p`,{children:e.Title||`Gift #${e.GiftID}`})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:c,"aria-label":r(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body collectible-modal-body`,children:[o?(0,H.jsxs)(`div`,{className:`collectible-loading`,children:[(0,H.jsx)(A,{className:`spin`}),r(`common.loading`)]}):i?.found?(0,H.jsxs)(`section`,{className:`collectible-active`,children:[(0,H.jsxs)(`div`,{className:`collectible-active-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(ne,{size:18}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.activeRevision`,{revision:i.revision??0})}),(0,H.jsxs)(`span`,{children:[i.slug_prefix,` · ⭐ `,i.upgrade_stars,` · `,i.issued,` / `,i.supply_total]})]})]}),(0,H.jsx)(G,{tone:`good`,children:r(`collectibles.published`)})]}),(0,H.jsxs)(`div`,{className:`collectible-active-grid`,children:[[...i.models??[],...i.patterns??[]].map(t=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(St,{giftID:e.GiftID,attribute:t}),(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`strong`,{children:[t.name,t.crafted&&(0,H.jsx)(G,{children:`crafted`})]}),(0,H.jsxs)(`span`,{children:[r(`collectibles.${t.kind}`),` · `,Tt(t)]})]})]},`${t.kind}-${t.id}`)),(i.backdrops??[]).map(e=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, #${(e.center_color??0).toString(16).padStart(6,`0`)}, #${(e.edge_color??0).toString(16).padStart(6,`0`)})`,color:`#${(e.text_color??16777215).toString(16).padStart(6,`0`)}`},children:`Aa`}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e.name}),(0,H.jsxs)(`span`,{children:[r(`collectibles.backdrop`),` · `,Tt(e)]})]})]},`backdrop-${e.id}`))]})]}):(0,H.jsxs)(`div`,{className:`collectible-empty`,children:[(0,H.jsx)(ne,{size:22}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.noPool`)}),(0,H.jsx)(`span`,{children:r(`collectibles.noPoolHint`)})]})]}),(0,H.jsxs)(`section`,{className:`collectible-definition`,children:[(0,H.jsxs)(`div`,{className:`collectible-definition-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.publishNew`)}),(0,H.jsx)(`span`,{children:r(`collectibles.immutableHint`)})]}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid collectible-main-fields`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:m,onChange:e=>{h(e.target.value),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>{v(e.target.value),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:y,maxLength:48,onChange:e=>{S(e.target.value.toLowerCase()),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.reason`)}),(0,H.jsx)(`input`,{value:C,maxLength:1e3,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>w(e.target.value)})]})]}),ie(`models`,T,E),ie(`patterns`,D,O),(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.backdrops`)}),(0,H.jsx)(`span`,{children:r(`collectibles.colorHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(G,{tone:P.backdrops>0?`good`:`neutral`,children:[P.backdrops,`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{N([...M,bt()]),F()},children:[(0,H.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:M.map((e,t)=>(0,H.jsxs)(`div`,{className:`collectible-row backdrop`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:t+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:e.name,maxLength:128,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,name:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.backdropID`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:e.backdropID,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,backdropID:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:e.rarity,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,rarity:t.target.value}:n)),F()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:e.sortOrder,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,sortOrder:t.target.value}:n)),F()}})]}),[`center`,`edge`,`pattern`,`text`].map(t=>(0,H.jsxs)(`label`,{className:`collectible-color`,children:[(0,H.jsx)(`span`,{children:r(`collectibles.color.${t}`)}),(0,H.jsx)(`input`,{type:`color`,value:e[t],onChange:n=>{N(M.map(r=>r.key===e.key?{...r,[t]:n.target.value}:r)),F()}})]},t)),(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, ${e.center}, ${e.edge})`,color:e.text},children:`Aa`}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:M.length===1,onClick:()=>{N(M.filter(t=>t.key!==e.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(he,{size:14})})]},e.key))})]})]}),u&&(0,H.jsx)(Ke,{children:u}),f&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(k,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.validationReady`)}),(0,H.jsx)(`span`,{children:r(`collectibles.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(f.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:c,children:r(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:te,disabled:c,children:[c?(0,H.jsx)(A,{className:`spin`,size:15}):(0,H.jsx)(fe,{size:15}),r(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:re,disabled:c||!f,children:[(0,H.jsx)(ge,{size:15}),r(`collectibles.publish`)]})]})]})}),document.body)}function Dt(e){return e.model_count+e.pattern_count+e.backdrop_count}function Ot(e){let t=Number(e);return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`}function kt({giftID:e,revision:t,compact:n=!1}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(!0),[s,c]=(0,g.useState)(``);(0,g.useEffect)(()=>{let t=!1;return x.giftAnimation(e).then(e=>{t||!r.current||(i.current?.destroy(),i.current=gt.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(e=>c(b(e))),()=>{t=!0,i.current?.destroy(),i.current=null}},[e,t]);function l(){i.current&&(a?i.current.pause():i.current.play(),o(!a))}return(0,H.jsxs)(`div`,{className:`gift-animation-shell ${n?`compact`:``}`,children:[(0,H.jsx)(`div`,{className:`gift-animation`,ref:r,children:s&&(0,H.jsx)(`span`,{children:s})}),(0,H.jsx)(`button`,{className:`gift-play`,type:`button`,onClick:l,"aria-label":a?`Pause`:`Play`,children:a?(0,H.jsx)(le,{size:14}):(0,H.jsx)(ue,{size:14})})]})}function At({sourceGiftID:e}){let t=(0,g.useRef)(null);return(0,g.useEffect)(()=>{let n=!1,r=null;return x.officialGiftAnimation(e).then(e=>{n||!t.current||(r=gt.default.loadAnimation({container:t.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(()=>void 0),()=>{n=!0,r?.destroy()}},[e]),(0,H.jsx)(`div`,{className:`gift-animation-shell`,children:(0,H.jsx)(`div`,{className:`gift-animation`,ref:t})})}function jt(){let{t:e}=Ce(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(`official`),[p,m]=(0,g.useState)([]),[h,_]=(0,g.useState)(``),[v,y]=(0,g.useState)(`all`),[S,C]=(0,g.useState)(``),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(`0`),[O,j]=(0,g.useState)(`0`),[M,N]=(0,g.useState)(``),[P,F]=(0,g.useState)(`0`),[I,L]=(0,g.useState)(``),[ee,te]=(0,g.useState)(`50`),[re,ie]=(0,g.useState)(`50`),[ae,oe]=(0,g.useState)(`0`),[se,ce]=(0,g.useState)(!0),[le,ue]=(0,g.useState)(``),[V,pe]=(0,g.useState)(null),[me,he]=(0,g.useState)(!1),[_e,ye]=(0,g.useState)(``),[U,be]=(0,g.useState)(``);async function xe(){ye(``);try{n((await x.gifts()).Gifts??[])}catch(e){ye(b(e))}}(0,g.useEffect)(()=>{xe()},[]),(0,g.useEffect)(()=>{!a||d!==`official`||p.length>0||x.officialGifts().then(e=>m(e.gifts??[])).catch(e=>be(b(e)))},[a,d,p.length]);let Se=(0,g.useMemo)(()=>p.find(e=>e.source_gift_id===S)??null,[p,S]),we=(0,g.useMemo)(()=>({all:p.length,upgrade:p.filter(e=>e.can_upgrade).length,craft:p.filter(e=>e.can_craft).length,basic:p.filter(e=>!e.can_upgrade).length}),[p]),Te=(0,g.useMemo)(()=>{let e=h.trim().toLowerCase();return p.filter(t=>(v===`all`||v===`upgrade`&&t.can_upgrade||v===`craft`&&t.can_craft||v===`basic`&&!t.can_upgrade)&&(!e||t.source_gift_id.includes(e)||t.title.toLowerCase().includes(e)))},[p,h,v]),Ee=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)||t.SourceFormat.toLowerCase().includes(e)):t},[t,r]);function De(t,n=``){if(!l)throw Error(e(`gifts.fileRequired`));if(!le.trim())throw Error(e(`action.reasonRequired`));let r=new FormData;return r.set(`metadata`,JSON.stringify({command_id:n,reason:le.trim(),confirm:t,gift_id:P,title:I.trim(),stars:ee,convert_stars:re,enabled:se,sort_order:Number(ae)})),r.set(`file`,l,l.name),r}function Oe(t,n=``){if(!S)throw Error(e(`gifts.officialRequired`));if(!le.trim())throw Error(e(`action.reasonRequired`));return{command_id:n,reason:le.trim(),confirm:t,source_gift_id:S,gift_id:P,title:I.trim(),stars:ee,convert_stars:re,enabled:se,sort_order:Number(ae),include_collectible:w,upgrade_stars:E,supply_total:Number(O),slug_prefix:M.trim().toLowerCase()}}function ke(t){C(t.source_gift_id),L(t.title||e(`gifts.officialUnnamed`,{id:t.source_gift_id})),te(String(t.stars)),ie(String(t.convert_stars)),T(t.can_upgrade),D(t.upgrade_stars),j(String(t.availability_total||1)),N(`official-${t.source_gift_id}`),pe(null)}async function Ae(){he(!0),be(``),pe(null);try{pe(d===`official`?await x.importOfficialGift(Oe(!1)):await x.importGift(De(!1)))}catch(e){be(b(e))}finally{he(!1)}}async function je(){if(V){he(!0),be(``);try{d===`official`?await x.importOfficialGift(Oe(!0,V.command_id)):await x.importGift(De(!0,V.command_id)),pe(null),u(null),F(`0`),L(``),C(``),await xe(),o(!1)}catch(e){be(b(e))}finally{he(!1)}}}function Me(){F(`0`),L(``),te(`50`),ie(`50`),oe(`0`),ce(!0),ue(``),u(null),pe(null),be(``),f(`official`),C(``),_(``),y(`all`),o(!0)}function Ne(e){F(e.GiftID),L(e.Title),te(String(e.Stars)),ie(String(e.ConvertStars)),oe(String(e.SortOrder)),ce(e.Enabled),ue(``),u(null),pe(null),be(``),f(`official`),C(``),_(``),y(`all`),o(!0)}return(0,H.jsxs)(He,{title:e(`gifts.pageTitle`),eyebrow:e(`gifts.eyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>xe(),disabled:me,children:[(0,H.jsx)(de,{size:15}),` `,e(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:Me,children:[(0,H.jsx)(z,{size:15}),` `,e(`gifts.add`)]})]}),children:[_e&&(0,H.jsx)(Ke,{children:_e}),(0,H.jsxs)(`div`,{className:`metric-row gift-metrics`,children:[(0,H.jsx)(q,{label:e(`gifts.total`),value:String(t.length)}),(0,H.jsx)(q,{label:e(`gifts.enabled`),value:String(t.filter(e=>e.Enabled).length),tone:`good`}),(0,H.jsx)(q,{label:e(`gifts.received`),value:t.reduce((e,t)=>e+BigInt(t.ReceivedCount),0n).toString()}),(0,H.jsx)(q,{label:e(`gifts.formats`),value:`TGS / Lottie`})]}),(0,H.jsx)(Ue,{children:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`gifts.searchPlaceholder`)})]}),(0,H.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:Ee.length,total:t.length})})]})}),(0,H.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table gift-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`gifts.animation`)}),(0,H.jsx)(`th`,{children:e(`gifts.idRevision`)}),(0,H.jsx)(`th`,{children:e(`gifts.title`)}),(0,H.jsx)(`th`,{children:e(`gifts.price`)}),(0,H.jsx)(`th`,{children:e(`gifts.source`)}),(0,H.jsx)(`th`,{children:e(`gifts.received`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`common.updatedAt`)}),(0,H.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[Ee.map(t=>(0,H.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(kt,{giftID:t.GiftID,revision:t.Revision,compact:!0})}),(0,H.jsxs)(`td`,{className:`mono`,children:[t.GiftID,` / `,t.Revision]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{className:`gift-table-title`,children:t.Title||`Gift #${t.GiftID}`}),(0,H.jsxs)(`span`,{className:`gift-sort-order`,children:[e(`gifts.sortOrder`),`: `,t.SortOrder]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsxs)(`strong`,{className:`gift-table-price`,children:[`⭐ `,t.Stars]}),(0,H.jsxs)(`span`,{className:`gift-convert-price`,children:[`→ `,t.ConvertStars]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(G,{children:t.SourceFormat}),(0,H.jsx)(`span`,{className:`gift-source-size`,children:Ot(t.AnimationSize)})]}),(0,H.jsx)(`td`,{children:t.ReceivedCount}),(0,H.jsx)(`td`,{children:(0,H.jsx)(G,{tone:t.Enabled?`good`:`neutral`,children:t.Enabled?e(`common.enabled`):e(`common.disabled`)})}),(0,H.jsx)(`td`,{children:ze(t.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn collectible-button`,type:`button`,onClick:()=>c(t),children:[(0,H.jsx)(ne,{size:13}),e(`collectibles.manage`)]}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>Ne(t),children:e(`gifts.replace`)}),(0,H.jsx)($e,{compact:!0,tone:`neutral`,label:t.Enabled?e(`gifts.disable`):e(`gifts.enable`),path:`/api/actions/set-gift-enabled`,payload:()=>({gift_id:t.GiftID,enabled:!t.Enabled}),onDone:()=>void xe()})]})})]},t.GiftID)),Ee.length===0&&(0,H.jsx)(Je,{colSpan:9})]})]})}),a&&(0,Qe.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal gift-import-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":P===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:P}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:e(`gifts.importEyebrow`)}),(0,H.jsx)(`h2`,{children:P===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:P})})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>o(!1),disabled:me,"aria-label":e(`action.close`),children:(0,H.jsx)(ve,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body gift-import-modal-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${(d===`official`?S:l)?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepDetails`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${V?`done`:(d===`official`?S:l)?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepValidate`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${V?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepImport`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-source-tabs`,children:[(0,H.jsx)(`button`,{className:`btn ${d===`official`?`primary`:``}`,type:`button`,onClick:()=>{f(`official`),pe(null)},children:e(`gifts.officialSource`)}),(0,H.jsx)(`button`,{className:`btn ${d===`file`?`primary`:``}`,type:`button`,onClick:()=>{f(`file`),pe(null)},children:e(`gifts.fileSource`)})]}),d===`official`?(0,H.jsxs)(`section`,{className:`official-gift-picker`,children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.officialHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:p.length}),(0,H.jsx)(`span`,{children:`SHA-256`})]})]}),(0,H.jsxs)(`div`,{className:`official-gift-tools`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`input`,{value:h,onChange:e=>_(e.target.value),placeholder:e(`gifts.officialSearch`)})]}),(0,H.jsx)(`span`,{children:e(`gifts.officialResults`,{shown:Te.length,total:p.length})})]}),(0,H.jsx)(`div`,{className:`official-gift-categories`,role:`group`,"aria-label":e(`gifts.officialCategoryLabel`),children:[`all`,`upgrade`,`craft`,`basic`].map(t=>(0,H.jsxs)(`button`,{className:v===t?`active`:``,type:`button`,"aria-pressed":v===t,onClick:()=>y(t),children:[e(`gifts.officialCategory.${t}`),(0,H.jsx)(`span`,{children:we[t]})]},t))}),(0,H.jsxs)(`div`,{className:`official-gift-list`,role:`listbox`,"aria-label":e(`gifts.officialSelect`),children:[Te.map(t=>{let n=t.source_gift_id===S;return(0,H.jsxs)(`button`,{className:`official-gift-option ${n?`selected`:``}`,type:`button`,role:`option`,"aria-selected":n,onClick:()=>ke(t),children:[(0,H.jsxs)(`span`,{className:`official-gift-option-head`,children:[(0,H.jsx)(`strong`,{children:t.title||e(`gifts.officialUnnamed`,{id:t.source_gift_id})}),(0,H.jsxs)(`span`,{className:`mono`,children:[`#`,t.source_gift_id]})]}),(0,H.jsxs)(`span`,{className:`official-gift-option-meta`,children:[(0,H.jsxs)(`span`,{children:[`⭐ `,t.stars]}),(0,H.jsx)(`span`,{children:e(`gifts.officialAttributes`,{count:Dt(t)})})]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:t.can_upgrade?`yes`:`no`,children:t.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:t.can_craft?`craft`:`no`,children:t.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]},t.source_gift_id)}),Te.length===0&&(0,H.jsx)(`div`,{className:`official-gift-empty`,children:e(`gifts.officialEmpty`)})]}),Se&&(0,H.jsxs)(`div`,{className:`official-gift-selected`,children:[(0,H.jsx)(At,{sourceGiftID:Se.source_gift_id}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Se.title||e(`gifts.officialUnnamed`,{id:Se.source_gift_id})}),(0,H.jsx)(`span`,{className:`mono`,children:Se.source_gift_id}),(0,H.jsxs)(`small`,{children:[Se.model_count,` `,e(`collectibles.models`),` · `,Se.pattern_count,` `,e(`collectibles.patterns`),` · `,Se.backdrop_count,` `,e(`collectibles.backdrops`)]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:Se.can_upgrade?`yes`:`no`,children:Se.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:Se.can_craft?`craft`:`no`,children:Se.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]})]}),Se?.can_upgrade&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:w,onChange:e=>{T(e.target.checked),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.includeCollectible`)})]}),w&&(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:E,onChange:e=>{D(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:O,onChange:e=>{j(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:M,maxLength:48,onChange:e=>{N(e.target.value.toLowerCase()),pe(null)}})]})]})]})]}):(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.importHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,"aria-label":e(`gifts.formats`),children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`label`,{className:`gift-file-picker ${l?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:e=>{u(e.target.files?.[0]??null),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-file-icon`,children:(0,H.jsx)(R,{size:22})}),(0,H.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.animation`)}),(0,H.jsx)(`strong`,{children:l?l.name:e(`gifts.filePrompt`)}),(0,H.jsx)(`small`,{children:l?Ot(l.size):e(`gifts.fileHint`)})]}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:e(l?`gifts.changeFile`:`gifts.chooseFile`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.title`)}),(0,H.jsx)(`input`,{value:I,maxLength:128,placeholder:e(`gifts.titlePlaceholder`),onChange:e=>{L(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.stars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:ee,onChange:e=>{te(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.convertStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:re,onChange:e=>{ie(e.target.value),pe(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:ae,onChange:e=>{oe(e.target.value),pe(null)}})]})]}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:e(`gifts.reason`)}),(0,H.jsx)(`input`,{value:le,placeholder:e(`gifts.reasonPlaceholder`),onChange:e=>ue(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:se,onChange:e=>{ce(e.target.checked),pe(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.enableAfterImport`)})]}),U&&(0,H.jsx)(Ke,{children:U}),V&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(k,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e(`gifts.validationReady`)}),(0,H.jsx)(`span`,{children:e(`gifts.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(V.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>o(!1),disabled:me,children:e(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:Ae,disabled:me,children:[me?(0,H.jsx)(A,{className:`spin`,size:15}):(0,H.jsx)(fe,{size:15}),e(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:je,disabled:me||!V,children:[(0,H.jsx)(ge,{size:15}),e(`gifts.confirmImport`)]})]})]})}),document.body),s&&(0,H.jsx)(Et,{gift:s,onClose:()=>c(null),onPublished:()=>void xe()})]})}function Mt({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1];return n?(0,H.jsx)(tt,{id:Number(n),navigate:t}):r?(0,H.jsx)(ot,{id:Number(r),navigate:t}):e.path===`/accounts`?(0,H.jsx)(at,{navigate:t}):e.path===`/channels`?(0,H.jsx)(st,{navigate:t}):e.path===`/gifts`?(0,H.jsx)(jt,{}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,H.jsx)(mt,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,H.jsx)(ut,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,H.jsx)(pt,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,H.jsx)(ht,{navigate:t}):(0,H.jsx)(ct,{navigate:t})}function Y(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Oe());(0,g.useEffect)(()=>{let e=()=>r(Oe());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{x.session().then(e=>t(e.actor)).catch(e=>{if(e instanceof v&&e.status===401){t(null);return}t(null)})},[]);let i=e=>{window.history.pushState(null,``,e),r(Oe())};return e===void 0?(0,H.jsx)(Me,{}):e===null?(0,H.jsx)(Ze,{onLogin:t}):(0,H.jsx)(Ne,{actor:e,route:n,navigate:i,onLogout:()=>t(null),children:(0,H.jsx)(Mt,{route:n,navigate:i})})}_.createRoot(document.getElementById(`root`)).render((0,H.jsx)(g.StrictMode,{children:(0,H.jsx)(Se,{children:(0,H.jsx)(Y,{})})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css b/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css new file mode 100644 index 00000000..f38bf9a5 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#f3f5f7;--panel:#fff;--panel-subtle:#f8fafb;--panel-strong:#eef2f5;--line:#d9e1e8;--line-strong:#c2ccd6;--text:#101828;--muted:#667085;--muted-2:#98a2b3;--brand:#176d61;--brand-2:#245b9d;--good:#167447;--warn:#a15c07;--danger:#b42318;--sidebar:#11161d;--sidebar-soft:#1b222b;--sidebar-line:#2c3541;--focus:#176d6129;--shadow:0 18px 52px #10182824}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);margin:0;font:13px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button,input,textarea{font:inherit}a{color:inherit;text-decoration:none}.shell{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{color:#eef2f6;background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;height:100vh;padding:18px 12px;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:0 8px 24px #176d6142}.brand-mark{color:#fff;background:var(--brand);border:1px solid #fff3;border-radius:8px;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:#aeb8c4;margin-top:3px;font-size:11px;display:block}.sidebar-label{color:#8492a6;text-transform:uppercase;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{color:#8fa0b4;cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;width:100%;min-height:38px;padding:0 10px;font-size:12px;font-weight:800;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-section-chevron{color:#8fa0b4;justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{color:#c6d0dc;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;min-height:38px;padding:0 10px;display:grid}.nav-dot{background:#687789;border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{color:#cbd5df;background:#171d25;border:1px solid #27313c;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;min-height:32px;padding:0 8px;display:grid}.runtime-row strong{color:#fff;font-size:11px}.workspace{min-width:0}.topbar{z-index:20;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#fffffff0;justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.topbar h1{margin:2px 0 0;font-size:20px;line-height:1.2}.topbar-actions,.page-actions,.section-action,.entity-badges,.row-actions,.modal-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.language-switch{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:2px;display:inline-flex}.language-switch button{min-width:42px;min-height:24px;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0 9px;font-weight:800}.language-switch button.active{color:#fff;background:var(--brand)}.language-switch button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.actor-pill{color:#344054;background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.overview-band h2,.page-title-row h2,.section-head h2,.modal h2{margin:0;font-size:18px;line-height:1.25}.overview-metrics,.metric-row{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:7px;min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:#afd8bf}.status-item.warn,.metric.warn{border-color:#e7c77e}.metric.danger{border-color:#efb4ad}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:8px;grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;min-height:94px;padding:14px;display:grid}.launcher:hover{border-color:var(--brand)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:8px;place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{color:#344054;background:var(--panel);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;min-height:38px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{border:1px solid var(--line);background:#fff;border-radius:8px;gap:8px;min-width:0;padding:10px;display:grid}.picker-head{color:#344054;justify-content:space-between;align-items:center;gap:8px;min-height:24px;font-weight:800;display:flex}.selected-entity{color:#0f3f38;background:#eef8f5;border:1px solid #b9dcd3;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;min-height:40px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:#52606d;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:7px;max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;background:#fff;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:#f3f8f6}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:#fff2f0;border:1px solid #efb4ad;border-radius:7px}input,textarea{color:var(--text);border:1px solid var(--line-strong);background:#fff;border-radius:7px;outline:none}input{width:190px;height:34px;padding:0 10px}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{border:1px solid var(--line-strong);background:#fff;border-radius:7px;align-items:center;gap:8px;width:min(380px,100%);height:34px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{color:#1d2939;border:1px solid var(--line-strong);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;justify-content:center;align-items:center;gap:6px;min-height:34px;padding:0 12px;display:inline-flex}.btn:hover:not(:disabled){background:#f7f9fb}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;background:var(--brand);border-color:var(--brand)}.btn.primary:hover:not(:disabled){background:#12594f}.btn.ghost{background:var(--panel-subtle)}.btn.danger{color:var(--danger);background:#fff7f5;border-color:#efb4ad}.btn.danger:hover:not(:disabled){background:#ffeceb}.btn.warn{color:var(--warn);background:#fff8ec;border-color:#e7c77e}.btn.warn:hover:not(:disabled){background:#fff1d6}.btn:disabled,.btn.primary:disabled,.btn.warn:disabled,.btn.danger:disabled{color:var(--muted-2);border-color:var(--line);cursor:not-allowed;background:#f3f5f7}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{border:1px solid var(--line);border-radius:8px;width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:#475467;background:var(--panel-strong);font-weight:800;position:sticky;top:0}.data-table tbody tr:hover{background:#fbfcfd}.data-table tr:last-child td{border-bottom:0}.mono{font-family:SFMono-Regular,Consolas,Liberation Mono,monospace}.truncate{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{color:#4f5b68;white-space:nowrap;background:#f3f6f8;border:1px solid #d7e0e8;border-radius:999px;align-items:center;min-height:22px;padding:1px 8px;display:inline-flex}.badge.good{color:var(--good);background:#eef8f2;border-color:#b9dcc7}.badge.danger{color:var(--danger);background:#fff2f0;border-color:#efb4ad}.badge.warn{color:var(--warn);background:#fff8e7;border-color:#e7c77e}.empty-cell{color:var(--muted);text-align:center}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:#344054;border:1px solid var(--line);background:#fbfcfd;border-radius:8px;margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0;padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:#344054;border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{align-items:center;gap:6px;width:100%;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:#8a251d;background:#fff2f0;border:1px solid #efb4ad;border-radius:8px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{color:#d8e6f0;background:#141a22;border:1px solid #2a3542;border-radius:8px;max-height:520px;margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;place-items:center;display:grid}.gift-metrics .metric{background:linear-gradient(145deg,#fff,#f6f9f9);min-height:68px;padding:12px}.gift-metrics .metric strong{font-size:17px}.gift-file-icon{color:var(--brand);background:#eaf6f3;border:1px solid #c7e3dc;flex:none;place-items:center;display:grid}.gift-format-chips{flex-wrap:wrap;flex:none;justify-content:flex-end;gap:6px;display:flex}.gift-format-chips span{color:#33645d;letter-spacing:.02em;background:#eef8f5;border:1px solid #cfe5df;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:800}.gift-list-summary{color:var(--muted);margin-left:auto;font-size:11px;font-weight:700}.gift-import-modal{width:min(860px,100%)}.gift-import-modal-body{gap:14px}.gift-source-tabs{gap:8px;display:flex}.official-gift-picker{gap:12px;min-width:0;display:grid}.official-gift-tools{align-items:center;gap:12px;display:flex}.official-gift-tools .searchbox{width:100%}.official-gift-tools>span{color:var(--muted);flex:none;font-size:11px;font-weight:750}.official-gift-categories{flex-wrap:wrap;gap:7px;display:flex}.official-gift-categories button{color:#49605c;min-height:32px;font:inherit;cursor:pointer;background:#f7faf9;border:1px solid #d7e2df;border-radius:999px;align-items:center;gap:7px;padding:5px 10px;font-size:11px;font-weight:800;transition:color .15s,background .15s,border-color .15s,box-shadow .15s;display:inline-flex}.official-gift-categories button:hover{color:var(--brand);border-color:#9fc9c0}.official-gift-categories button.active{color:#fff;background:var(--brand);border-color:var(--brand);box-shadow:0 4px 12px #176d612b}.official-gift-categories button span{min-width:20px;height:20px;color:inherit;background:#ffffffa6;border-radius:999px;place-items:center;padding:0 5px;font-size:10px;display:grid}.official-gift-categories button.active span{color:var(--brand)}.official-gift-list{border:1px solid var(--line);scrollbar-gutter:stable;background:#f6f9f8;border-radius:14px;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-height:126px;max-height:314px;padding:8px;display:grid;overflow:auto}.official-gift-option{text-align:left;min-width:0;color:var(--text);cursor:pointer;background:#fff;border:1px solid #dce6e3;border-radius:11px;gap:8px;padding:11px 12px;transition:border-color .15s,box-shadow .15s,transform .15s;display:grid;box-shadow:0 1px 2px #20363208}.official-gift-option:hover{border-color:#9fc9c0;transform:translateY(-1px);box-shadow:0 5px 14px #204c4414}.official-gift-option.selected{border-color:var(--brand);box-shadow:0 0 0 2px #176d611f,0 5px 14px #204c4414}.official-gift-option-head{grid-template-columns:minmax(0,1fr) auto;align-items:baseline;gap:8px;display:grid}.official-gift-option-head strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.official-gift-option-head .mono{color:var(--muted);font-size:9px}.official-gift-option-meta{color:#667773;flex-wrap:wrap;gap:10px;font-size:10px;font-weight:700;display:flex}.official-gift-capabilities{flex-wrap:wrap;gap:5px;display:flex}.official-gift-capabilities>span{letter-spacing:.01em;border:1px solid #0000;border-radius:999px;padding:3px 7px;font-size:9px;font-weight:850}.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{min-height:108px;color:var(--muted);text-align:center;grid-column:1/-1;place-items:center;padding:20px;font-size:12px;display:grid}.official-gift-selected{border:1px solid var(--line);background:var(--surface-soft);border-radius:14px;grid-template-columns:108px minmax(0,1fr);align-items:center;gap:14px;padding:12px;display:grid}.official-gift-selected .gift-animation-shell{width:96px;height:96px}.official-gift-selected>div:last-child{gap:5px;min-width:0;display:grid}.official-gift-selected small{color:var(--muted)}.gift-import-note{color:var(--muted);justify-content:space-between;align-items:center;gap:12px;line-height:1.45;display:flex}.gift-file-picker{min-height:78px;color:var(--text);cursor:pointer;background:#fff;border:1px dashed #b7ccc8;border-radius:10px;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 14px;transition:border-color .16s,background .16s,box-shadow .16s;display:grid;position:relative}.gift-file-picker:hover,.gift-file-picker.has-file{border-color:var(--brand);background:#f8fcfb;box-shadow:0 0 0 2px #176d610d}.gift-file-picker input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.gift-file-icon{border-radius:9px;width:40px;height:40px}.gift-file-copy{gap:2px;min-width:0;display:grid}.gift-field-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;font-weight:800}.gift-file-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.gift-file-copy small{color:var(--muted);font-size:11px;font-weight:500}.gift-file-action{color:var(--brand);background:#f0f8f6;border:1px solid #c7e3dc;border-radius:7px;padding:7px 10px;font-size:11px;font-weight:800}.gift-fields-grid{grid-template-columns:minmax(200px,1.5fr) repeat(3,minmax(120px,1fr));gap:10px;display:grid}.gift-fields-grid label,.gift-reason-field{color:var(--muted);gap:6px;font-size:11px;font-weight:700;display:grid}.gift-fields-grid input,.gift-reason-field input{width:100%;min-width:0;height:38px;color:var(--text);border:1px solid var(--line);background:#fff;border-radius:7px;padding:0 10px}.gift-fields-grid input:focus,.gift-reason-field input:focus{border-color:#77b6aa;outline:none;box-shadow:0 0 0 3px #176d6114}.gift-switch{color:#344054;cursor:pointer;align-items:center;gap:9px;font-size:12px;font-weight:700;display:inline-flex}.gift-switch input{opacity:0;width:1px;height:1px;position:absolute}.gift-switch-track{background:#c8d0d5;border-radius:999px;align-items:center;width:34px;height:19px;padding:2px;transition:background .16s;display:flex}.gift-switch-track span{background:#fff;border-radius:50%;width:15px;height:15px;transition:transform .16s;box-shadow:0 1px 3px #10182838}.gift-switch input:checked+.gift-switch-track{background:var(--brand)}.gift-switch input:checked+.gift-switch-track span{transform:translate(15px)}.gift-switch input:focus-visible+.gift-switch-track{outline-offset:2px;outline:3px solid #176d6129}.gift-validation{color:#d5fff5;background:#173631;border:1px solid #24564e;border-radius:9px;overflow:hidden}.gift-validation-head{color:#e3fff9;background:#ffffff09;border-bottom:1px solid #ffffff17;align-items:center;gap:9px;padding:10px 12px;display:flex}.gift-validation-head div{gap:2px;display:grid}.gift-validation-head span{color:#99cfc4;font-size:10px}.gift-validation pre{color:#d5fff5;max-height:180px;margin:0;padding:11px 12px;font-size:11px;overflow:auto}.gift-animation-shell{background:radial-gradient(circle,#f9f3ff,#eef8f5);place-items:center;min-height:210px;display:grid;position:relative}.gift-animation{width:200px;height:200px}.gift-animation canvas{width:100%!important;height:100%!important}.gift-play{width:30px;height:30px;color:var(--text);border:1px solid var(--line);background:#ffffffe6;border-radius:50%;place-items:center;display:grid;position:absolute;bottom:8px;right:8px}.gift-table-wrap{background:#fff}.gift-table{min-width:1080px}.gift-table th:first-child{width:74px}.gift-table td{vertical-align:middle}.gift-animation-shell.compact{border:1px solid var(--line);border-radius:9px;width:56px;min-height:56px;overflow:hidden}.gift-animation-shell.compact .gift-animation{width:54px;height:54px}.gift-animation-shell.compact .gift-play{width:20px;height:20px;bottom:3px;right:3px}.gift-row-disabled{opacity:.68}.gift-table-title,.gift-sort-order,.gift-source-size,.gift-convert-price{display:block}.gift-table-title{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.gift-sort-order,.gift-source-size,.gift-convert-price{color:var(--muted);margin-top:3px;font-size:10px}.gift-table-price{color:#755b00}.gift-table-actions{align-items:center;gap:6px;display:flex}.collectible-button{color:#6548a8;background:#f7f3ff;border-color:#ddd2f5}.collectible-button:hover{background:#efe8ff;border-color:#cbbaf0}.collectible-modal{width:min(1180px,100%);max-height:min(92vh,980px)}.collectible-modal .modal-head p{color:var(--muted);margin:4px 0 0;font-size:11px}.collectible-modal-body{background:#f5f7fa;gap:16px;padding:16px 18px 22px;overflow:auto}.collectible-loading{min-height:90px;color:var(--muted);justify-content:center;align-items:center;gap:8px;display:flex}.collectible-empty{color:#66568c;background:linear-gradient(135deg,#fbf9ff,#f2f7ff);border:1px dashed #cfc3e9;border-radius:12px;align-items:center;gap:12px;padding:16px;display:flex}.collectible-empty div,.collectible-definition-head>div:first-child,.collectible-section-head>div:first-child{gap:3px;display:grid}.collectible-empty span,.collectible-definition-head span,.collectible-section-head span{color:var(--muted);font-size:10px;font-weight:500}.collectible-active{background:#fff;border:1px solid #ddd6ee;border-radius:12px;overflow:hidden;box-shadow:0 5px 16px #422e6e0d}.collectible-active-head{background:linear-gradient(100deg,#fbf9ff,#f4f9ff);border-bottom:1px solid #e9e4f3;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.collectible-active-head>div{color:#60458f;align-items:center;gap:9px;display:flex}.collectible-active-head>div>div{gap:2px;display:grid}.collectible-active-head span{color:var(--muted);font-size:10px}.collectible-active-grid{background:var(--line);grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:1px;display:grid}.collectible-active-grid article{background:#fff;align-items:center;gap:9px;min-width:0;padding:9px 11px;display:flex}.collectible-active-grid article>div:last-child{gap:2px;min-width:0;display:grid}.collectible-active-grid article strong{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.collectible-active-grid article span{color:var(--muted);font-size:9px}.collectible-definition{border:1px solid var(--line);background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px #1018280a}.collectible-definition-head{border-bottom:1px solid var(--line);background:linear-gradient(110deg,#f8fbfa,#fbf9ff);justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.collectible-main-fields{border-bottom:1px solid var(--line);background:#fbfcfd;padding:14px 16px}.collectible-section{border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section:last-child{border-bottom:0}.collectible-section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.collectible-section-tools{align-items:center;gap:7px;display:flex}.collectible-rows{gap:7px;display:grid}.collectible-row{background:#fafbfc;border:1px solid #e1e6eb;border-radius:9px;align-items:end;gap:7px;padding:9px 9px 9px 36px;display:grid;position:relative}.collectible-row:hover{background:#fff;border-color:#cbd7dd;box-shadow:0 3px 10px #10182809}.collectible-row.animated{grid-template-columns:minmax(120px,1.2fr) 90px 78px minmax(160px,1.4fr) 48px 30px}.collectible-row.backdrop{grid-template-columns:minmax(110px,1.2fr) 70px 80px 70px repeat(4,52px) 48px 30px}.collectible-row-index{color:#71668c;background:#f0edf7;border-right:1px solid #e0d9ed;border-radius:8px 0 0 8px;place-items:center;width:27px;font-size:10px;font-weight:800;display:grid;position:absolute;top:0;bottom:0;left:0}.collectible-row label{gap:4px;min-width:0;display:grid}.collectible-row label>span{color:var(--muted);text-transform:uppercase;letter-spacing:.025em;font-size:9px;font-weight:800}.collectible-row input:not([type=file]){width:100%;min-width:0;height:32px;color:var(--text);font:inherit;background:#fff;border:1px solid #d5dde3;border-radius:7px;padding:0 8px;font-size:11px}.collectible-row input:focus{border-color:#8d7aba;outline:none;box-shadow:0 0 0 3px #6f5bae14}.collectible-file input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.collectible-file em{color:#625080;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;background:#f7f4fd;border:1px dashed #cfc4e1;border-radius:7px;align-items:center;gap:5px;min-width:0;height:32px;padding:0 8px;font-size:10px;font-style:normal;font-weight:700;display:flex;overflow:hidden}.collectible-inline-preview{color:#8c7cae;background:radial-gradient(circle,#fff,#eee8f8);border:1px solid #ded5ed;border-radius:8px;place-items:center;width:42px;height:42px;display:grid;overflow:hidden}.collectible-animation{width:100%;height:100%;overflow:hidden}.collectible-animation.compact{background:radial-gradient(circle,#fff,#f0ebfa);border:1px solid #e0d9ec;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;display:grid}.collectible-animation canvas{width:100%!important;height:100%!important}.collectible-animation.failed{color:#b42318;background:#fff4f2}.collectible-animation.loading{color:#807397}.collectible-file-error{color:#b42318;grid-column:1/-1;font-size:10px}.collectible-color input{cursor:pointer;height:32px!important;padding:3px!important}.collectible-backdrop-preview{border:1px solid #2a1f472e;border-radius:8px;flex:0 0 42px;place-items:center;width:42px;height:42px;font-size:11px;font-weight:900;display:grid;box-shadow:inset 0 0 0 1px #fff3}.collectible-row .icon-btn{align-self:center}.collectible-row .icon-btn:disabled{opacity:.28}@media (width<=900px){.gift-fields-grid,.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:repeat(2,minmax(0,1fr))}.collectible-inline-preview,.collectible-backdrop-preview,.collectible-row .icon-btn{place-self:center start}}@media (width<=620px){.gift-import-note{flex-direction:column;align-items:flex-start}.gift-format-chips{justify-content:flex-start}.gift-file-picker{grid-template-columns:40px minmax(0,1fr)}.gift-file-action{display:none}.gift-fields-grid{grid-template-columns:1fr}.gift-list-summary{width:100%;margin-left:0}.official-gift-tools{flex-direction:column;align-items:stretch}.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{flex-direction:column;align-items:flex-start}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:1fr}.collectible-active-grid{grid-template-columns:1fr 1fr}}.modal-backdrop{z-index:10000;background:#11182785;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);background:#fff;border-radius:8px;padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.command-modal>.modal-head,.command-modal>.modal-actions{flex:none}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:7px;place-items:center;width:30px;height:30px;display:grid}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{grid-auto-rows:max-content;gap:12px;min-height:0;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{border:1px solid var(--line);background:#fff;border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:#a9d8ce}.command-step.done{color:var(--good);border-color:#b9dcc7}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:#4b5563;font-weight:800}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:#344054;align-items:center;gap:7px;font-weight:800;display:flex}.result-box{border:1px solid var(--line);background:#fbfcfd;border-radius:8px;gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:#344054}.modal-actions{border-top:1px solid var(--line);background:#fff;justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{border:1px solid var(--line);width:min(420px,100%);box-shadow:var(--shadow);background:#fff;border-radius:8px;gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-head-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:#d7dde4;border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav-list{grid-template-columns:repeat(4,minmax(0,1fr))}.sidebar-status{display:none}.overview-band,.split-layout,.operation-row,.raw-grid,.message-selector-grid,.message-selector-grid.single{grid-template-columns:1fr}.action-dock{position:static}}@media (width<=760px){.content,.topbar{padding-left:14px;padding-right:14px}.command-grid,.work-strip,.overview-metrics,.metric-row,.summary-grid,.command-steps{grid-template-columns:1fr}.sidebar{gap:12px;padding:14px}.nav-list{grid-template-columns:repeat(2,minmax(0,1fr))}.topbar,.page-title-row,.entity-head{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js b/cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js deleted file mode 100644 index bcab617c..00000000 --- a/cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js +++ /dev/null @@ -1,9 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,M(x);else{var t=n(l);t!==null&&N(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&N(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,N(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,M(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` -`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ne(e):``}function ae(e){switch(e.tag){case 5:return ne(e.type);case 16:return ne(`Lazy`);case 13:return ne(`Suspense`);case 19:return ne(`SuspenseList`);case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return``}}function oe(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case M:return`Suspense`;case N:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case j:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case P:return t=e.displayName||null,t===null?oe(e.type)||`Memo`:t;case F:t=e._payload,e=e._init;try{return oe(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return oe(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ce(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ue(e){var t=le(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function z(e){e._valueTracker||=ue(e)}function de(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=le(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function B(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function fe(e,t){var n=t.checked;return R({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function pe(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ce(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function me(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function he(e,t){me(e,t);var n=ce(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?_e(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&_e(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ge(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function _e(e,t,n){(t!==`number`||B(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var ve=Array.isArray;function ye(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=U.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Te(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ee={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},De=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ee).forEach(function(e){De.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ee[t]=Ee[e]})});function Oe(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ee.hasOwnProperty(e)&&Ee[e]?(``+t).trim():t+`px`}function ke(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Oe(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ae=R({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function je(e,t){if(t){if(Ae[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Me(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ne=null;function Pe(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Fe=null,Ie=null,Le=null;function Re(e){if(e=ji(e)){if(typeof Fe!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Ni(t),Fe(e.stateNode,e.type,t))}}function ze(e){Ie?Le?Le.push(e):Le=[e]:Ie=e}function Be(){if(Ie){var e=Ie,t=Le;if(Le=Ie=null,Re(e),t)for(e=0;e>>=0,e===0?32:31-(vt(e)/yt|0)|0}var xt=64,St=4194304;function Ct(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function wt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Ct(a))):r=Ct(s)}else o=n&~i,o===0?a!==0&&(r=Ct(a)):r=Ct(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function At(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function jt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=X),Jn=` `,Yn=!1;function Xn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Zn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Qn=!1;function $n(e,t){switch(e){case`compositionend`:return Zn(t);case`keypress`:return t.which===32?(Yn=!0,Jn):null;case`textInput`:return e=t.data,e===Jn&&Yn?null:e;default:return null}}function er(e,t){if(Qn)return e===`compositionend`||!Gn&&Xn(e,t)?(e=hn(),mn=pn=fn=null,Qn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(){for(var e=window,t=B();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=B(e.document)}return t}function Tr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Er(e){var t=wr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Cr(n.ownerDocument.documentElement,n)){if(r!==null&&Tr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Sr(n,a);var o=Sr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Or=null,kr=null,Ar=null,jr=!1;function Mr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;jr||Or==null||Or!==B(r)||(r=Or,`selectionStart`in r&&Tr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ar&&br(Ar,r)||(Ar=r,r=ii(kr,`onSelect`),0Fi||(e.current=Pi[Fi],Pi[Fi]=null,Fi--)}function Ri(e,t){Fi++,Pi[Fi]=e.current,e.current=t}var zi={},Bi=Ii(zi),Vi=Ii(!1),Hi=zi;function Ui(e,t){var n=e.type.contextTypes;if(!n)return zi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Wi(e){return e=e.childContextTypes,e!=null}function Gi(){Li(Vi),Li(Bi)}function Ki(e,t,n){if(Bi.current!==zi)throw Error(r(168));Ri(Bi,t),Ri(Vi,n)}function qi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,se(e)||`Unknown`,a));return R({},n,i)}function Ji(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||zi,Hi=Bi.current,Ri(Bi,e),Ri(Vi,Vi.current),!0}function Yi(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=qi(e,t,Hi),i.__reactInternalMemoizedMergedChildContext=e,Li(Vi),Li(Bi),Ri(Bi,e)):Li(Vi),Ri(Vi,n)}var Xi=null,Zi=!1,Qi=!1;function $i(e){Xi===null?Xi=[e]:Xi.push(e)}function ea(e){Zi=!0,$i(e)}function ta(){if(!Qi&&Xi!==null){Qi=!0;var e=0,t=Y;try{var n=Xi;for(Y=1;e>=o,i-=o,la=1<<32-_t(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),_a&&da(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),_a&&da(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return _a&&da(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),_a&&da(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===F&&ja(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=ka(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=ka(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case F:return l=i._init,_(e,r,l(i._payload),o)}if(ve(i))return h(e,r,i,o);if(ee(i))return g(e,r,i,o);Aa(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Na=Ma(!0),Pa=Ma(!1),Fa=Ii(null),Ia=null,La=null,Ra=null;function za(){Ra=La=Ia=null}function Ba(e){var t=Fa.current;Li(Fa),e._currentValue=t}function Va(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Ha(e,t){Ia=e,Ra=La=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ms=!0),e.firstContext=null)}function Ua(e){var t=e._currentValue;if(Ra!==e)if(e={context:e,memoizedValue:t,next:null},La===null){if(Ia===null)throw Error(r(308));La=e,Ia.dependencies={lanes:0,firstContext:e}}else La=La.next=e;return t}var Wa=null;function Ga(e){Wa===null?Wa=[e]:Wa.push(e)}function Ka(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Ga(t)):(n.next=i.next,i.next=n),t.interleaved=n,qa(e,r)}function qa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Ja=!1;function Ya(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Xa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Za(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Qa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,qa(e,n)}return i=r.interleaved,i===null?(t.next=t,Ga(r)):(t.next=i.next,i.next=t),r.interleaved=t,qa(e,n)}function $a(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}function eo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function to(e,t,n,r){var i=e.updateQueue;Ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=R({},d,f);break a;case 2:Ja=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function no(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=vo.transition;vo.transition={};try{e(!1),t()}finally{Y=n,vo.transition=r}}function as(){return Mo().memoizedState}function os(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},cs(e))ls(t,n);else if(n=Ka(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),us(n,t,r)}}function ss(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(cs(e))ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Z(s,o)){var c=t.interleaved;c===null?(i.next=i,Ga(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ka(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),us(n,t,r))}}function cs(e){var t=e.alternate;return e===bo||t!==null&&t===bo}function ls(e,t){wo=Co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Mt(e,n)}}var ds={readContext:Ua,useCallback:Do,useContext:Do,useEffect:Do,useImperativeHandle:Do,useInsertionEffect:Do,useLayoutEffect:Do,useMemo:Do,useReducer:Do,useRef:Do,useState:Do,useDebugValue:Do,useDeferredValue:Do,useTransition:Do,useMutableSource:Do,useSyncExternalStore:Do,useId:Do,unstable_isNewReconciler:!1},fs={readContext:Ua,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:Ua,useEffect:Jo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Ko(4194308,4,Qo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ko(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ko(4,2,e,t)},useMemo:function(e,t){var n=jo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=jo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=os.bind(null,bo,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:Uo,useDebugValue:es,useDeferredValue:function(e){return jo().memoizedState=e},useTransition:function(){var e=Uo(!1),t=e[0];return e=is.bind(null,e[1]),jo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=bo,a=jo();if(_a){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));yo&30||Ro(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Jo(Bo.bind(null,i,o,e),[e]),i.flags|=2048,Wo(9,zo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=jo(),t=Vc.identifierPrefix;if(_a){var n=ua,r=la;n=(r&~(1<<32-_t(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=To++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[wi]=t,e[Ti]=i,nc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Me(n,i),n){case`dialog`:Zr(`cancel`,e),Zr(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:Zr(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}else{if(!i)if(e=mo(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ac(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!_a)return oc(t),null}else 2*st()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(oc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=st(),t.sibling=null,n=po.current,Ri(po,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(oc(t),t.subtreeFlags&6&&(t.flags|=8192)):oc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function cc(e,t){switch(ma(t),t.tag){case 1:return Wi(t.type)&&Gi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return lo(),Li(Vi),Li(Bi),go(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return fo(t),null;case 13:if(Li(po),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Ea()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Li(po),null;case 4:return lo(),null;case 10:return Ba(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var lc=!1,uc=!1,dc=typeof WeakSet==`function`?WeakSet:Set,Q=null;function fc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function pc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var mc=!1;function hc(e,t){if(fi=an,e=wr(),Tr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(pi={focusedElem:e,selectionRange:n},an=!1,Q=t;Q!==null;)if(t=Q,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Q=e;else for(;Q!==null;){t=Q;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:hs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Q=e;break}Q=t.return}return h=mc,mc=!1,h}function gc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&pc(t,n,a)}i=i.next}while(i!==r)}}function _c(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function yc(e){var t=e.alternate;t!==null&&(e.alternate=null,yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[wi],delete t[Ti],delete t[Di],delete t[Oi],delete t[ki])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bc(e){return e.tag===5||e.tag===3||e.tag===4}function xc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||bc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=di));else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}var wc=null,Tc=!1;function Ec(e,t,n){for(n=n.child;n!==null;)Dc(e,t,n),n=n.sibling}function Dc(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount==`function`)try{ht.onCommitFiberUnmount(mt,n)}catch{}switch(n.tag){case 5:uc||fc(n,t);case 6:var r=wc,i=Tc;wc=null,Ec(e,t,n),wc=r,Tc=i,wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):wc.removeChild(n.stateNode));break;case 18:wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?bi(e.parentNode,n):e.nodeType===1&&bi(e,n),nn(e)):bi(wc,n.stateNode));break;case 4:r=wc,i=Tc,wc=n.stateNode.containerInfo,Tc=!0,Ec(e,t,n),wc=r,Tc=i;break;case 0:case 11:case 14:case 15:if(!uc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&pc(n,t,o),i=i.next}while(i!==r)}Ec(e,t,n);break;case 1:if(!uc&&(fc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Ec(e,t,n);break;case 21:Ec(e,t,n);break;case 22:n.mode&1?(uc=(r=uc)||n.memoizedState!==null,Ec(e,t,n),uc=r):Ec(e,t,n);break;default:Ec(e,t,n)}}function Oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new dc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function kc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=st()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Lc(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,$&6)throw Error(r(331));var a=$;for($|=4,Q=e.current;Q!==null;){var o=Q,s=o.child;if(Q.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lst()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=St,St<<=1,!(St&130023424)&&(St=4194304)):t=1);var n=fl();e=qa(e,t),e!==null&&(At(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Vi.current)Ms=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ms=!1,tc(e,t,n);Ms=!!(e.flags&131072)}else Ms=!1,_a&&t.flags&1048576&&fa(t,aa,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;$s(e,t),e=t.pendingProps;var a=Ui(t,Bi.current);Ha(t,n),a=ko(null,t,i,e,a,n);var o=Ao();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Wi(i)?(o=!0,Ji(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ya(t),a.updater=_s,t.stateNode=a,a._reactInternals=t,xs(t,i,e,n),t=Vs(null,t,i,!0,o,n)):(t.tag=0,_a&&o&&pa(t),Ns(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch($s(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=hs(i,e),a){case 0:t=zs(null,t,i,e,n);break a;case 1:t=Bs(null,t,i,e,n);break a;case 11:t=Ps(null,t,i,e,n);break a;case 14:t=Fs(null,t,i,hs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),zs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Bs(e,t,i,a,n);case 3:a:{if(Hs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Xa(e,t),to(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Ss(Error(r(423)),t),t=Us(e,t,i,n,a);break a}else if(i!==a){a=Ss(Error(r(424)),t),t=Us(e,t,i,n,a);break a}else for(ga=xi(t.stateNode.containerInfo.firstChild),ha=t,_a=!0,va=null,n=Pa(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Ea(),i===a){t=ec(e,t,n);break a}Ns(e,t,i,n)}t=t.child}return t;case 5:return uo(t),e===null&&Sa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,mi(i,a)?s=null:o!==null&&mi(i,o)&&(t.flags|=32),Rs(e,t),Ns(e,t,s,n),t.child;case 6:return e===null&&Sa(t),null;case 13:return Ks(e,t,n);case 4:return co(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Na(t,null,i,n):Ns(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Ps(e,t,i,a,n);case 7:return Ns(e,t,t.pendingProps,n),t.child;case 8:return Ns(e,t,t.pendingProps.children,n),t.child;case 12:return Ns(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,Ri(Fa,i._currentValue),i._currentValue=s,o!==null)if(Z(o.value,s)){if(o.children===a.children&&!Vi.current){t=ec(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=Za(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Va(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Va(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Ns(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Ha(t,n),a=Ua(a),i=i(a),t.flags|=1,Ns(e,t,i,n),t.child;case 14:return i=t.type,a=hs(i,t.pendingProps),a=hs(i.type,a),Fs(e,t,i,a,n);case 15:return Is(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),$s(e,t),t.tag=1,Wi(i)?(e=!0,Ji(t)):e=!1,Ha(t,n),ys(t,i,a),xs(t,i,a,n),Vs(null,t,i,!0,e,n);case 19:return Qs(e,t,n);case 22:return Ls(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return rt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===j)return 11;if(e===P)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case M:return e=Kl(13,n,t,a),e.elementType=M,e.lanes=o,e;case N:return e=Kl(19,n,t,a),e.elementType=N,e.lanes=o,e;case I:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case j:s=11;break a;case P:s=14;break a;case F:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=I,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=kt(0),this.expirationTimes=kt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=kt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ya(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}};async function y(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{credentials:`same-origin`,headers:n?t.headers:{"Content-Type":`application/json`,...t.headers??{}},...t}),i=await r.text(),a=i?JSON.parse(i):null;if(!r.ok){let e=a?.error||a?.Error||a?.message||r.statusText;throw new v(r.status,e)}return a}function b(e){return e instanceof Error?e.message:String(e)}var x={session:()=>y(`/api/session`),login:e=>y(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})}),logout:()=>y(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>y(`/api/accounts?${e.toString()}`),account:e=>y(`/api/accounts/${e}`),channels:e=>y(`/api/channels?${e.toString()}`),channel:e=>y(`/api/channels/${e}`),messages:e=>y(`/api/messages?${e.toString()}`),message:(e,t)=>y(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>y(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>y(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),gifts:()=>y(`/api/gifts`),giftAnimation:e=>y(`/api/gifts/${e}/animation`),giftCollectibles:e=>y(`/api/gifts/${e}/collectibles`),giftCollectibleAnimation:(e,t,n)=>y(`/api/gifts/${e}/collectibles/${t}/${n}/animation`),importGift:e=>y(`/api/actions/import-gift`,{method:`POST`,body:e}),publishGiftCollectibles:(e,t)=>y(`/api/actions/publish-gift-collectibles?gift_id=${e}`,{method:`POST`,body:t}),action:(e,t)=>y(e,{method:`POST`,body:JSON.stringify(t)})},S=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),C=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),w={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},T=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...w,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:C(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),E=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(T,{ref:i,iconNode:t,className:C(`lucide-${S(e)}`,n),...r}));return n.displayName=`${e}`,n},D=E(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),O=E(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),k=E(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),A=E(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),j=E(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),M=E(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),N=E(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),P=E(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),F=E(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),I=E(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),L=E(`Clock3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16.5 12`,key:`1aq6pp`}]]),ee=E(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),R=E(`FileJson2`,[[`path`,{d:`M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4`,key:`1pf5j1`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M4 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`fq0c9t`}],[`path`,{d:`M8 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`4gibmv`}]]),te=E(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),ne=E(`Gem`,[[`path`,{d:`M6 3h12l4 6-10 13L2 9Z`,key:`1pcd5k`}],[`path`,{d:`M11 3 8 9l4 13 4-13-3-6`,key:`1fcu3u`}],[`path`,{d:`M2 9h20`,key:`16fsjt`}]]),re=E(`Gift`,[[`rect`,{x:`3`,y:`8`,width:`18`,height:`4`,rx:`1`,key:`bkv52`}],[`path`,{d:`M12 8v13`,key:`1c76mn`}],[`path`,{d:`M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7`,key:`6wjy6b`}],[`path`,{d:`M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5`,key:`1ihvrl`}]]),ie=E(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),ae=E(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),oe=E(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),se=E(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),ce=E(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),le=E(`Pause`,[[`rect`,{x:`14`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`zuxfzm`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`1okwgv`}]]),ue=E(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),z=E(`Plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),de=E(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),B=E(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),fe=E(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),pe=E(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),me=E(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),he=E(`Star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),ge=E(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),_e=E(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),ve=E(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),ye=E(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),be=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),V=o(((e,t)=>{t.exports=be()}))(),H=`telesrv.admin.lang`,xe={en:{"app.adminConsole":`Admin Console`,"app.localAccess":`Local access`,"app.title":`telesrv admin`,"common.actions":`Actions`,"common.admins":`Admins`,"common.backToList":`Back to list`,"common.channel":`Channel`,"common.channelOrGroup":`Channel / Group`,"common.clear":`Clear`,"common.close":`Close`,"common.count":`Count`,"common.deleted":`Deleted`,"common.detail":`Details`,"common.device":`Device`,"common.disabled":`Disabled`,"common.enabled":`Enabled`,"common.fromPeer":`From Peer`,"common.group":`Group`,"common.id":`ID`,"common.limit":`Limit`,"common.loading":`Loading`,"common.member":`Member`,"common.members":`Members`,"common.messageId":`Message ID`,"common.name":`Name`,"common.no":`No`,"common.noResults":`No results`,"common.none":`None`,"common.normal":`Normal`,"common.operations":`Operations`,"common.owner":`Owner`,"common.platform":`Platform`,"common.refresh":`Refresh`,"common.search":`Search`,"common.sender":`Sender`,"common.status":`Status`,"common.survived":`Live`,"common.time":`Time`,"common.type":`Type`,"common.updatedAt":`Updated`,"common.username":`Username`,"common.valid":`Valid`,"common.verified":`Verified`,"common.views":`Views`,"common.yes":`Yes`,"route.accounts":`Accounts`,"route.accountsSubtitle":`Console / Accounts`,"route.channels":`Supergroups and Channels`,"route.channelsSubtitle":`Console / Channels`,"route.dashboard":`Operations Console`,"route.dashboardSubtitle":`Console / Overview`,"route.messages":`Message Audit`,"route.messagesSubtitle":`Console / Messages`,"route.gifts":`Star Gifts`,"route.giftsSubtitle":`Console / Star Gifts`,"layout.navigation":`Navigation`,"layout.primaryNav":`Primary navigation`,"layout.dashboard":`Overview`,"layout.accounts":`Accounts`,"layout.channels":`Supergroups / Channels`,"layout.messages":`Messages`,"layout.gifts":`Star Gifts`,"layout.privateMessages":`Private`,"layout.groupMessages":`Groups`,"layout.runtime":`Runtime`,"layout.adminBackend":`Admin backend`,"layout.ready":`Ready`,"layout.pgRead":`PG read`,"layout.readOnly":`Read-only`,"layout.writeOps":`Write operations`,"layout.dryRun":`Dry-run`,"layout.actor":`Actor: {actor}`,"layout.logout":`Log out`,"language.en":`EN`,"language.zh":`中文`,"login.heading":`Operations Admin`,"login.body":`Enter credentials to open the console.`,"login.secret":`Admin password or token`,"login.submit":`Log in`,"login.submitting":`Logging in`,"dashboard.eyebrow":`Runtime Overview`,"dashboard.title":`Console Overview`,"dashboard.readPath":`Read path`,"dashboard.readPathValue":`PG read-only`,"dashboard.writePath":`Write path`,"dashboard.executionPolicy":`Execution policy`,"dashboard.dryRunFirst":`Dry-run first`,"dashboard.accountsText":`Account status, premium, verification, sessions.`,"dashboard.channelsText":`Public entities, member counts, verification state.`,"dashboard.messagesText":`Message boxes, updates, outbox state.`,"dashboard.strip.dryRun":`All dangerous actions start with dry-run`,"dashboard.strip.token":`Browser never stores internal tokens`,"dashboard.strip.pagination":`Lists use cursor pagination`,"dashboard.strip.snapshot":`Detail pages retain raw state snapshots`,"account.pageTitle":`Accounts`,"account.queryResults":`Search results`,"account.recentActive":`Recently active accounts`,"account.currentPage":`Accounts on page`,"account.onlineDevices":`Online device records`,"account.premium":`Premium`,"account.frozen":`Frozen`,"account.searchPlaceholder":`User ID / phone / username`,"account.userID":`User ID`,"account.phone":`Phone`,"account.lastActive":`Last active`,"account.notVerified":`Not verified`,"account.notPremium":`Not premium`,"account.premiumUntil":`Premium expires`,"account.starsBalance":`Stars balance`,"account.startingGrantApplied":`initial grant applied`,"account.startingGrantPending":`initial grant pending`,"account.activeSessions":`Authorized devices`,"account.accountFlags":`Account flags`,"account.restriction":`Restriction`,"account.restricted":`Restricted`,"account.createdAt":`Created`,"account.detailTitle":`Account #{id}`,"account.profile":`Account Profile`,"account.loadingDetail":`Loading account detail`,"account.waitingData":`Waiting for data`,"account.noUsername":`No username`,"account.noPhone":`No phone`,"account.accountFrozen":`Account frozen`,"account.accountActive":`Account active`,"account.authorizationsTitle":`Authorized Devices`,"account.authorizationsCount":`{count} authorizations`,"account.recentAdminOps":`Recent Admin Actions`,"account.recent30Audit":`Last 30 audit rows`,"account.actionDock":`Account Actions`,"account.freezeAccount":`Freeze account`,"account.updateFreeze":`Update freeze`,"account.unfreezeAccount":`Unfreeze account`,"account.freezeSince":`Frozen since`,"account.freezeUntil":`Appeal deadline`,"account.freezeUntilAria":`Freeze appeal deadline`,"account.freezeAppealURL":`Appeal URL`,"account.freezeAppealURLAria":`Freeze appeal URL`,"account.premiumMonths":`Premium duration (months)`,"account.premiumMonthsAria":`Set premium duration in months`,"account.setPremium":`Set premium`,"account.clearPremium":`Clear premium`,"account.starsAmount":`Stars to grant`,"account.starsAmountAria":`Set Stars amount to grant`,"account.grantStars":`Grant Stars`,"account.setVerified":`Set verified`,"account.clearVerified":`Clear verified`,"channel.pageTitle":`Supergroups and Channels`,"channel.recentUpdated":`Recently updated`,"channel.currentPage":`Entities on page`,"channel.megagroups":`Supergroups`,"channel.broadcasts":`Channels`,"channel.verifiedCount":`Verified`,"channel.searchPlaceholder":`Channel ID / username / title`,"channel.channelID":`Channel ID`,"channel.kind":`Kind`,"channel.title":`Title`,"channel.pts":`PTS`,"channel.detailProfile":`Channel Profile`,"channel.loadingDetail":`Loading channel detail`,"channel.creator":`Creator {id}`,"channel.governance":`Moderation`,"channel.governanceValue":`Banned {banned} / Kicked {kicked}`,"channel.flags":`Channel flags`,"channel.rawRow":`Channel Raw Row`,"channel.rawRowText":`Database read-only snapshot`,"channel.actionDock":`Channel Actions`,"channel.setVerified":`Set verified`,"channel.clearVerified":`Clear verified`,"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`,"messages.privateTitle":`Private Messages`,"messages.privateEyebrow":`Private message boxes`,"messages.groupTitle":`Group Messages`,"messages.groupEyebrow":`Supergroup / channel messages`,"messages.selectPrivatePeers":`Search and select the owner user and peer user first`,"messages.selectChannel":`Search and select a supergroup or channel first`,"messages.ownerUser":`Owner user`,"messages.peerUser":`Peer user`,"messages.beforeDatePlaceholder":`before_date cursor`,"messages.beforeIDPlaceholder":`before_msg_id cursor`,"messages.limitPlaceholder":`limit <= 100`,"messages.searchMessages":`Search messages`,"messages.nextPage":`Next page`,"messages.currentPage":`Messages on page`,"messages.deleted":`Deleted`,"messages.outgoing":`Outgoing`,"messages.incoming":`Incoming`,"messages.ownerPeer":`Owner / Peer`,"messages.deleteSelected":`Delete selected messages`,"messages.idsPlaceholder":`Message IDs, comma separated`,"messages.revoke":`Revoke for both sides`,"messages.previewDelete":`Dry-run delete`,"messages.clearHistory":`Clear private history`,"messages.maxIDPlaceholder":`max_id cutoff`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Clear only this side`,"messages.previewClearHistory":`Dry-run clear history`,"messages.direction":`Direction`,"messages.body":`Body`,"messages.privateDetailTitle":`Message #{id}`,"messages.detailEyebrow":`Message Detail`,"messages.backPrivate":`Back to private messages`,"messages.backGroup":`Back to group messages`,"messages.ownerPeerTitle":`Owner {owner} · Peer {peer}`,"messages.senderSubtitle":`Sender {sender} · {date}`,"messages.boxID":`Message box ID`,"messages.privateMessageID":`Private message ID`,"messages.messageSender":`Message sender`,"messages.messageBox":`Message Box`,"messages.dialogRow":`Dialog Row`,"messages.privateRow":`Private Message Row`,"messages.channelMessageRow":`Channel Message Row`,"messages.channelRow":`Channel Row`,"messages.userUpdateEvents":`Update Events`,"messages.channelUpdateEvents":`Channel Update Events`,"messages.eventJson":`Event JSON`,"messages.dispatchOutbox":`Dispatch Queue`,"messages.messageBoxesSnapshot":`message_boxes read-only snapshot`,"messages.dialogSnapshot":`dialogs read-only snapshot`,"messages.privateSnapshot":`private_messages read-only snapshot`,"messages.channelMessagesSnapshot":`channel_messages read-only snapshot`,"messages.channelSnapshot":`channels read-only snapshot`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`online/offline dispatch_outbox`,"messages.attempts":`Attempts`,"messages.deleteThis":`Delete this message`,"messages.groupDetailTitle":`Group Message #{id}`,"messages.channelGroupTitle":`Channel / Group {id}`,"messages.mediaCount":`With media`,"messages.channelPosts":`Channel posts`,"messages.channelGroup":`Channel / Group`,"messages.pinned":`Pinned`,"messages.channelPost":`Channel post`,"gifts.pageTitle":`Star Gift Catalog`,"gifts.eyebrow":`Catalog, immutable revisions and animation assets`,"gifts.total":`Catalog entries`,"gifts.enabled":`Enabled`,"gifts.received":`Received gifts`,"gifts.formats":`Accepted formats`,"gifts.add":`Add gift`,"gifts.searchPlaceholder":`Search gift ID, title or format`,"gifts.listSummary":`Showing {shown} of {total}`,"gifts.idRevision":`ID / Revision`,"gifts.price":`Price / Conversion`,"gifts.importTitle":`Import a Star Gift`,"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.animation":`Animation file`,"gifts.filePrompt":`Drop or choose a TGS / Lottie file`,"gifts.fileHint":`TGS, JSON or Lottie · validated before import`,"gifts.chooseFile":`Choose file`,"gifts.changeFile":`Change file`,"gifts.title":`Display title`,"gifts.titlePlaceholder":`e.g. Celebration Star`,"gifts.stars":`Price in Stars`,"gifts.convertStars":`Conversion Stars`,"gifts.sortOrder":`Sort order`,"gifts.reason":`Audit reason`,"gifts.reasonPlaceholder":`Briefly describe why this gift is being imported`,"gifts.enableAfterImport":`Enable after import`,"gifts.validate":`Dry-run validation`,"gifts.confirmImport":`Confirm import`,"gifts.stepDetails":`File and details`,"gifts.stepValidate":`Dry-run validation`,"gifts.stepImport":`Confirm import`,"gifts.fileRequired":`Choose a TGS or Lottie file first`,"gifts.source":`Source`,"gifts.replace":`New revision`,"gifts.disable":`Disable`,"gifts.enable":`Enable`,"gifts.empty":`No Star Gifts have been imported.`,"gifts.emptyHint":`Import the first animation above to build the gift catalog.`,"gifts.validationReady":`Validation passed`,"gifts.validationHint":`Review the normalized metadata, then confirm the import.`,"gifts.confirmState":`Apply the validated state change to gift #{id}?`,"collectibles.manage":`Attribute pool`,"collectibles.title":`Collectible pool · Gift #{id}`,"collectibles.eyebrow":`Unique gift attributes`,"collectibles.activeRevision":`Published revision {revision}`,"collectibles.published":`Published`,"collectibles.noPool":`No collectible pool published`,"collectibles.noPoolHint":`Publish models, patterns and backdrops to enable upgrades.`,"collectibles.publishNew":`Publish a new immutable revision`,"collectibles.immutableHint":`Dry-run checks every file and rarity total before the revision becomes active.`,"collectibles.upgradeStars":`Upgrade price in Stars`,"collectibles.supply":`Unique supply`,"collectibles.slug":`Public slug prefix`,"collectibles.models":`Models`,"collectibles.patterns":`Patterns`,"collectibles.backdrops":`Backdrops`,"collectibles.model":`Model`,"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.addAttribute":`Add`,"collectibles.remove":`Remove attribute`,"collectibles.fileRequired":`Every model and pattern needs a TGS or Lottie file.`,"collectibles.backdropID":`Backdrop ID`,"collectibles.color.center":`Center`,"collectibles.color.edge":`Edge`,"collectibles.color.pattern":`Pattern`,"collectibles.color.text":`Text`,"collectibles.validationReady":`Attribute pool is valid`,"collectibles.validationHint":`Review the normalized assets, then publish this immutable revision.`,"collectibles.publish":`Publish revision`,"messages.msgIDsInvalid":`Message IDs are invalid`,"auth.device":`Device`,"auth.platform":`Platform`,"auth.ip":`IP`,"auth.lastActive":`Last active`,"auth.revokeCurrent":`Revoke current`,"auth.keepCurrent":`Keep current`,"auth.revokeAll":`Revoke all devices`,"picker.userPlaceholder":`Search user_id / phone / username`,"picker.channelPlaceholder":`Search channel_id / username / title`,"picker.verified":`Verified`,"picker.regular":`Regular`,"action.reasonRequired":`Please enter an operation reason`,"action.flow":`Action Flow`,"action.close":`Close`,"action.stepReason":`Enter reason`,"action.stepDryRun":`Dry-run check`,"action.stepConfirm":`Confirm execution`,"action.reason":`Operation reason`,"action.reasonPlaceholder":`Describe why this operation is being performed`,"action.requestPreview":`Request preview`,"action.result":`Action result`,"action.commandID":`Command ID`,"action.status":`Status`,"action.dryRun":`Dry-run`,"action.runAgain":`Run dry-run again`,"action.runDry":`Run dry-run first`,"action.confirm":`Confirm execution`,"audit.id":`ID`,"audit.commandID":`Command ID`,"audit.action":`Action`,"audit.actor":`Actor`,"audit.status":`Status`,"audit.dryRun":`Dry-run`,"audit.reason":`Reason`,"audit.time":`Time`},zh:{"app.adminConsole":`管理控制台`,"app.localAccess":`本地访问`,"app.title":`telesrv 管理后台`,"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":`From Peer`,"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":`中文`,"login.heading":`运维后台`,"login.body":`输入凭据后进入控制台。`,"login.secret":`管理员密码或 token`,"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":`消息盒、update、outbox 状态。`,"dashboard.strip.dryRun":`所有危险操作先预演`,"dashboard.strip.token":`浏览器不持有内部 token`,"dashboard.strip.pagination":`列表使用游标分页`,"dashboard.strip.snapshot":`详情页保留原始状态快照`,"account.pageTitle":`账号`,"account.queryResults":`查询结果`,"account.recentActive":`最近活跃账号`,"account.currentPage":`当前页账号`,"account.onlineDevices":`在线设备记录`,"account.premium":`会员`,"account.frozen":`冻结`,"account.searchPlaceholder":`用户 ID / 手机号 / 用户名`,"account.userID":`用户 ID`,"account.phone":`手机号`,"account.lastActive":`最近活跃`,"account.notVerified":`未认证`,"account.notPremium":`非会员`,"account.premiumUntil":`会员到期`,"account.starsBalance":`Stars 余额`,"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":`申诉链接`,"account.freezeAppealURLAria":`账号冻结申诉链接`,"account.premiumMonths":`会员时长(月)`,"account.premiumMonthsAria":`设置会员时长,单位月`,"account.setPremium":`设置会员`,"account.clearPremium":`取消会员`,"account.starsAmount":`赠送 Stars 数量`,"account.starsAmountAria":`设置要赠送的 Stars 数量`,"account.grantStars":`赠送 Stars`,"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":`分发队列`,"messages.messageBoxesSnapshot":`message_boxes 只读快照`,"messages.dialogSnapshot":`dialogs 只读快照`,"messages.privateSnapshot":`private_messages 只读快照`,"messages.channelMessagesSnapshot":`channel_messages 只读快照`,"messages.channelSnapshot":`channels 只读快照`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable 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.animation":`动画文件`,"gifts.filePrompt":`拖放或选择 TGS / Lottie 文件`,"gifts.fileHint":`支持 TGS、JSON、Lottie,导入前会先进行校验`,"gifts.chooseFile":`选择文件`,"gifts.changeFile":`更换文件`,"gifts.title":`显示标题`,"gifts.titlePlaceholder":`例如:庆典星星`,"gifts.stars":`售价 Stars`,"gifts.convertStars":`可兑换 Stars`,"gifts.sortOrder":`排序值`,"gifts.reason":`审计原因`,"gifts.reasonPlaceholder":`简要说明本次导入礼物的原因`,"gifts.enableAfterImport":`导入后启用`,"gifts.validate":`Dry-run 校验`,"gifts.confirmImport":`确认导入`,"gifts.stepDetails":`文件与信息`,"gifts.stepValidate":`Dry-run 校验`,"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":`Collectibles 属性池 · 礼物 #{id}`,"collectibles.eyebrow":`唯一礼物属性管理`,"collectibles.activeRevision":`已发布版本 {revision}`,"collectibles.published":`已发布`,"collectibles.noPool":`尚未发布 Collectibles 属性池`,"collectibles.noPoolHint":`发布模型、图案与背景后,客户端即可升级为唯一礼物。`,"collectibles.publishNew":`发布新的不可变版本`,"collectibles.immutableHint":`Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。`,"collectibles.upgradeStars":`升级价格 Stars`,"collectibles.supply":`唯一礼物总量`,"collectibles.slug":`公开 Slug 前缀`,"collectibles.models":`模型`,"collectibles.patterns":`图案`,"collectibles.backdrops":`背景`,"collectibles.model":`模型`,"collectibles.pattern":`图案`,"collectibles.backdrop":`背景`,"collectibles.rarity":`稀有度 ‰`,"collectibles.rarityHint":`每一类的稀有度总和必须正好为 1000‰。`,"collectibles.colorHint":`颜色会按 Telegram 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 / phone / username`,"picker.channelPlaceholder":`搜索 channel_id / username / title`,"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":`时间`}},Se=(0,g.createContext)(null);function Ce({children:e}){let[t,n]=(0,g.useState)(()=>Ee());(0,g.useEffect)(()=>{try{localStorage.setItem(H,t)}catch{}document.documentElement.lang=t===`zh`?`zh-CN`:`en`,document.documentElement.dir=`ltr`,document.documentElement.setAttribute(`translate`,`no`),document.body.classList.add(`notranslate`),document.title=Te(t,`app.title`)},[t]);let r=(0,g.useMemo)(()=>({lang:t,setLang:n,t:(e,n)=>Te(t,e,n)}),[t]);return(0,V.jsx)(Se.Provider,{value:r,children:e})}function U(){let e=(0,g.useContext)(Se);if(!e)throw Error(`useI18n must be used inside I18nProvider`);return e}function we(){let{lang:e,setLang:t,t:n}=U();return(0,V.jsx)(`div`,{className:`language-switch`,role:`group`,"aria-label":`Language`,children:[`en`,`zh`].map(r=>(0,V.jsx)(`button`,{className:e===r?`active`:``,type:`button`,"aria-pressed":e===r,onClick:()=>t(r),children:n(`language.${r}`)},r))})}function Te(e,t,n){let r=xe[e][t]??xe.en[t]??t;return n?r.replace(/\{(\w+)\}/g,(e,t)=>String(n[t]??``)):r}function Ee(){try{let e=De(new URLSearchParams(window.location.search).get(`lang`));if(e)return e}catch{}try{let e=De(localStorage.getItem(H));if(e)return e}catch{}let e=navigator.languages?.length?navigator.languages:[navigator.language];for(let t of e){let e=De(t);if(e)return e}return`en`}function De(e){if(!e)return null;let t=e.trim().toLowerCase().replace(`_`,`-`);return t===`zh`||t.startsWith(`zh-`)?`zh`:t===`en`||t.startsWith(`en-`)?`en`:null}function Oe(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function ke(e,t){return e.startsWith(`/accounts`)?t(`route.accounts`):e.startsWith(`/channels`)?t(`route.channels`):e.startsWith(`/messages`)?t(`route.messages`):e.startsWith(`/gifts`)?t(`route.gifts`):t(`route.dashboard`)}function Ae(e,t){return e.startsWith(`/accounts`)?t(`route.accountsSubtitle`):e.startsWith(`/channels`)?t(`route.channelsSubtitle`):e.startsWith(`/messages`)?t(`route.messagesSubtitle`):e.startsWith(`/gifts`)?t(`route.giftsSubtitle`):t(`route.dashboardSubtitle`)}function je({href:e,navigate:t,className:n,children:r}){return(0,V.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function Me(){let{t:e}=U();return(0,V.jsxs)(`div`,{className:`boot-screen`,children:[(0,V.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:e(`app.adminConsole`)})]})]}),(0,V.jsx)(`div`,{className:`loader-bar`})]})}function Ne({actor:e,route:t,navigate:n,onLogout:r,children:i}){let{t:a}=U(),o=t.path.startsWith(`/messages`),[s,c]=(0,g.useState)(o);(0,g.useEffect)(()=>{o&&c(!0)},[o]);async function l(){await x.logout().catch(()=>void 0),r()}return(0,V.jsxs)(`div`,{className:`shell`,children:[(0,V.jsxs)(`aside`,{className:`sidebar`,children:[(0,V.jsxs)(je,{className:`brand`,href:`/`,navigate:n,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:a(`app.adminConsole`)})]})]}),(0,V.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.navigation`)}),(0,V.jsxs)(`nav`,{className:`nav-list`,"aria-label":a(`layout.primaryNav`),children:[(0,V.jsx)(Pe,{icon:(0,V.jsx)(oe,{size:16}),href:`/`,route:t,navigate:n,children:a(`layout.dashboard`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(ve,{size:16}),href:`/accounts`,route:t,navigate:n,children:a(`layout.accounts`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(pe,{size:16}),href:`/channels`,route:t,navigate:n,children:a(`layout.channels`)}),(0,V.jsx)(Pe,{icon:(0,V.jsx)(re,{size:16}),href:`/gifts`,route:t,navigate:n,children:a(`layout.gifts`)}),(0,V.jsxs)(`div`,{className:`nav-section ${o?`active`:``} ${s?`open`:``}`,children:[(0,V.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":s,onClick:()=>c(e=>!e),children:[(0,V.jsx)(ce,{size:16}),(0,V.jsx)(`span`,{children:a(`layout.messages`)}),(0,V.jsx)(F,{className:`nav-section-chevron`,size:15})]}),s&&(0,V.jsxs)(`div`,{className:`nav-children`,children:[(0,V.jsx)(Pe,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:a(`layout.privateMessages`)}),(0,V.jsx)(Pe,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:a(`layout.groupMessages`)})]})]})]}),(0,V.jsxs)(`div`,{className:`sidebar-status`,children:[(0,V.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.runtime`)}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(fe,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.adminBackend`)}),(0,V.jsx)(`strong`,{children:a(`layout.ready`)})]}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(ee,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.pgRead`)}),(0,V.jsx)(`strong`,{children:a(`layout.readOnly`)})]}),(0,V.jsxs)(`div`,{className:`runtime-row`,children:[(0,V.jsx)(me,{size:14}),(0,V.jsx)(`span`,{children:a(`layout.writeOps`)}),(0,V.jsx)(`strong`,{children:a(`layout.dryRun`)})]})]})]}),(0,V.jsxs)(`div`,{className:`workspace`,children:[(0,V.jsxs)(`header`,{className:`topbar`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:Ae(t.path,a)}),(0,V.jsx)(`h1`,{children:ke(t.path,a)})]}),(0,V.jsxs)(`div`,{className:`topbar-actions`,children:[(0,V.jsx)(we,{}),(0,V.jsx)(`span`,{className:`actor-pill`,children:a(`layout.actor`,{actor:e})}),(0,V.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:l,title:a(`layout.logout`),children:[(0,V.jsx)(se,{size:16}),` `,a(`layout.logout`)]})]})]}),(0,V.jsx)(`main`,{className:`content`,children:i})]})]})}function Pe({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,V.jsxs)(je,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,V.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,V.jsx)(`span`,{children:i})]})}function Fe(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function Ie(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Le(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function Re(e,t){let n=t??(e=>({"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`})[e]??e);return e.Broadcast&&!e.Megagroup?n(`channel.kind.broadcast`):e.Megagroup&&e.Forum?n(`channel.kind.forum`):e.Megagroup?n(`channel.kind.megagroup`):n(`channel.kind.generic`)}function ze(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Be(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function W(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ve(e,t=`msg ids invalid`){let n=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(n.length===0||n.some(e=>!Number.isFinite(e)||e<=0))throw Error(t);return n}function He({title:e,eyebrow:t,children:n,actions:r}){return(0,V.jsxs)(`div`,{className:`page-frame`,children:[(0,V.jsxs)(`div`,{className:`page-title-row`,children:[(0,V.jsxs)(`div`,{children:[t&&(0,V.jsx)(`div`,{className:`eyebrow`,children:t}),(0,V.jsx)(`h2`,{children:e})]}),r&&(0,V.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function Ue({children:e}){return(0,V.jsx)(`div`,{className:`query-panel`,children:e})}function We({main:e,side:t}){return(0,V.jsxs)(`div`,{className:`split-layout`,children:[(0,V.jsx)(`div`,{className:`split-main`,children:e}),(0,V.jsx)(`aside`,{className:`split-side`,children:t})]})}function Ge({title:e,text:t,action:n}){return(0,V.jsxs)(`div`,{className:`section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`h2`,{children:e}),t&&(0,V.jsx)(`p`,{children:t})]}),n&&(0,V.jsx)(`div`,{className:`section-action`,children:n})]})}function Ke({children:e}){return(0,V.jsxs)(`div`,{className:`alert`,children:[(0,V.jsx)(O,{size:16}),` `,(0,V.jsx)(`span`,{children:e})]})}function G({children:e,tone:t=`neutral`}){return(0,V.jsx)(`span`,{className:`badge ${t}`,children:e})}function K({label:e,value:t,tone:n}){return(0,V.jsxs)(`div`,{className:`status-item ${n}`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{children:t})]})}function q({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,V.jsxs)(`div`,{className:`metric ${n}`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function J({label:e,value:t,mono:n=!1}){return(0,V.jsxs)(`div`,{className:`summary-item`,children:[(0,V.jsx)(`span`,{children:e}),(0,V.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function qe({rows:e}){let{t}=U();return(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`audit.id`)}),(0,V.jsx)(`th`,{children:t(`audit.commandID`)}),(0,V.jsx)(`th`,{children:t(`audit.action`)}),(0,V.jsx)(`th`,{children:t(`audit.actor`)}),(0,V.jsx)(`th`,{children:t(`audit.status`)}),(0,V.jsx)(`th`,{children:t(`audit.dryRun`)}),(0,V.jsx)(`th`,{children:t(`audit.reason`)}),(0,V.jsx)(`th`,{children:t(`audit.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[e.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.ID}),(0,V.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,V.jsx)(`td`,{children:e.Action}),(0,V.jsx)(`td`,{children:e.Actor}),(0,V.jsx)(`td`,{children:e.Status}),(0,V.jsx)(`td`,{children:e.DryRun?t(`common.yes`):t(`common.no`)}),(0,V.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,V.jsx)(`td`,{children:ze(e.CreatedAt)})]},e.ID)),e.length===0&&(0,V.jsx)(Je,{colSpan:8})]})]})})}function Je({colSpan:e}){let{t}=U();return(0,V.jsx)(`tr`,{children:(0,V.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:t(`common.noResults`)})})}function Ye({label:e}){return(0,V.jsx)(`section`,{className:`surface`,children:(0,V.jsx)(`div`,{className:`loading-line`,children:e})})}function Xe({value:e}){return(0,V.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function Ze({onLogin:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1);async function c(t){t.preventDefault(),s(!0),a(``);try{e((await x.login(n)).actor)}catch(e){a(b(e))}finally{s(!1)}}return(0,V.jsx)(`main`,{className:`login-page`,children:(0,V.jsxs)(`section`,{className:`login-panel`,children:[(0,V.jsxs)(`div`,{className:`login-head`,children:[(0,V.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,V.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,V.jsxs)(`span`,{children:[(0,V.jsx)(`strong`,{children:`telesrv`}),(0,V.jsx)(`small`,{children:t(`app.adminConsole`)})]})]}),(0,V.jsxs)(`div`,{className:`login-head-actions`,children:[(0,V.jsx)(we,{}),(0,V.jsx)(`span`,{className:`login-chip`,children:t(`app.localAccess`)})]})]}),(0,V.jsxs)(`div`,{className:`login-copy`,children:[(0,V.jsx)(`h1`,{children:t(`login.heading`)}),(0,V.jsx)(`p`,{children:t(`login.body`)})]}),i&&(0,V.jsx)(Ke,{children:i}),(0,V.jsxs)(`form`,{className:`form-stack`,onSubmit:c,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:t(`login.secret`)}),(0,V.jsx)(`input`,{autoFocus:!0,type:`password`,value:n,autoComplete:`current-password`,onChange:e=>r(e.target.value)})]}),(0,V.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:o,children:t(o?`login.submitting`:`login.submit`)})]})]})})}var Qe=m();function $e({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,onDone:o}){let{t:s}=U(),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(!1);function y(){d(``),p(null),h(``)}async function S(e){if(!u.trim()){h(s(`action.reasonRequired`));return}v(!0),h(``);try{let r={...n(),reason:u,confirm:e};p(await x.action(t,r)),e&&o?.()}catch(e){h(b(e))}finally{v(!1)}}let C=f?.dry_run&&!f.error,w=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,T=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:b(e)}}},[c,n]);return(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`button`,{className:w,type:`button`,onClick:()=>{y(),l(!0)},children:[r,e]}),c&&(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:s(`action.flow`)}),(0,V.jsx)(`h2`,{children:e})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>l(!1),"aria-label":s(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body`,children:[(0,V.jsxs)(`div`,{className:`command-steps`,children:[(0,V.jsxs)(`div`,{className:`command-step ${u.trim()?`done`:`active`}`,children:[(0,V.jsx)(`span`,{children:`1`}),(0,V.jsx)(`strong`,{children:s(`action.stepReason`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${f?.dry_run?`done`:u.trim()?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`2`}),(0,V.jsx)(`strong`,{children:s(`action.stepDryRun`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${f&&!f.dry_run&&!f.error?`done`:C?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`3`}),(0,V.jsx)(`strong`,{children:s(`action.stepConfirm`)})]})]}),(0,V.jsxs)(`label`,{className:`form-field`,children:[(0,V.jsx)(`span`,{children:s(`action.reason`)}),(0,V.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:3,placeholder:s(`action.reasonPlaceholder`)})]}),(0,V.jsxs)(`div`,{className:`command-preview`,children:[(0,V.jsxs)(`div`,{className:`preview-head`,children:[(0,V.jsx)(te,{size:14}),` `,s(`action.requestPreview`)]}),(0,V.jsx)(Xe,{value:JSON.stringify(T,null,2)})]}),m&&(0,V.jsx)(Ke,{children:m}),f&&(0,V.jsxs)(`div`,{className:`result-box`,children:[(0,V.jsxs)(`div`,{className:`result-title`,children:[f.error?(0,V.jsx)(O,{size:16}):(0,V.jsx)(k,{size:16}),(0,V.jsx)(`strong`,{children:f.message||f.error||s(`action.result`)})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.commandID`)}),(0,V.jsx)(`strong`,{children:f.command_id})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.status`)}),(0,V.jsx)(`strong`,{children:f.status})]}),(0,V.jsxs)(`div`,{className:`result-line`,children:[(0,V.jsx)(`span`,{children:s(`action.dryRun`)}),(0,V.jsx)(`strong`,{children:f.dry_run?s(`common.yes`):s(`common.no`)})]}),(0,V.jsx)(`div`,{className:`result-message`,children:f.message||f.error}),f.details&&(0,V.jsx)(Xe,{value:JSON.stringify(f.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>l(!1),children:s(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(!1),disabled:_,children:[_?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(ue,{size:15}),s(f?`action.runAgain`:`action.runDry`)]}),(0,V.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>S(!0),disabled:_||!C,children:[(0,V.jsx)(k,{size:15}),s(`action.confirm`)]})]})]})}),document.body)]})}function et({rows:e,userID:t,onDone:n}){let{t:r}=U(),[i,a]=(0,g.useState)(()=>new Set);(0,g.useEffect)(()=>{a(new Set)},[t]);let o=(0,g.useMemo)(()=>e.filter(e=>!i.has(e.Hash)),[e,i]);function s(e){a(t=>e(t)),n()}return(0,V.jsxs)(`div`,{className:`authorization-block`,children:[(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:r(`auth.device`)}),(0,V.jsx)(`th`,{children:r(`auth.platform`)}),(0,V.jsx)(`th`,{children:r(`auth.ip`)}),(0,V.jsx)(`th`,{children:r(`auth.lastActive`)}),(0,V.jsx)(`th`,{className:`device-actions-head`,children:r(`common.actions`)})]})}),(0,V.jsxs)(`tbody`,{children:[o.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsxs)(`td`,{className:`device-text`,children:[n.DeviceModel,` `,n.SystemVersion]}),(0,V.jsxs)(`td`,{className:`device-text`,children:[n.Platform,` `,n.AppVersion]}),(0,V.jsx)(`td`,{children:n.IP}),(0,V.jsx)(`td`,{children:ze(n.ActiveAt)}),(0,V.jsx)(`td`,{className:`device-actions-cell`,children:(0,V.jsxs)(`div`,{className:`device-actions`,children:[(0,V.jsx)($e,{label:r(`auth.revokeCurrent`),icon:(0,V.jsx)(se,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:n.Hash}),onDone:()=>s(e=>new Set([...e,n.Hash]))}),(0,V.jsx)($e,{label:r(`auth.keepCurrent`),icon:(0,V.jsx)(pe,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:n.Hash}),onDone:()=>s(()=>new Set(e.filter(e=>e.Hash!==n.Hash).map(e=>e.Hash)))})]})})]},n.Hash)),o.length===0&&(0,V.jsx)(Je,{colSpan:5})]})]})}),(0,V.jsx)(`div`,{className:`danger-zone`,children:(0,V.jsx)($e,{label:r(`auth.revokeAll`),icon:(0,V.jsx)(N,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:()=>s(()=>new Set(e.map(e=>e.Hash)))})})]})}function tt({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(`1000`),[p,m]=(0,g.useState)(()=>nt(new Date(Date.now()+7*864e5))),[h,_]=(0,g.useState)(``);async function v(){c(!0),o(``);try{let t=await x.account(e);i(t),t.Restriction.Frozen&&(t.Restriction.Until&&m(nt(new Date(t.Restriction.Until))),_(t.Restriction.AppealURL||``))}catch(e){o(b(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{v()},[e]),a)return(0,V.jsx)(Ke,{children:a});if(!r)return(0,V.jsx)(Ye,{label:n(s?`account.loadingDetail`:`account.waitingData`)});let y=r.Account;return(0,V.jsx)(He,{title:n(`account.detailTitle`,{id:y.ID}),eyebrow:n(`account.profile`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,V.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:Le(y)}),(0,V.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(y.Username)||n(`account.noUsername`),` · `,Fe(y.Phone)||n(`account.noPhone`)]})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[y.PremiumUntil>0?(0,V.jsx)(G,{tone:`good`,children:n(`account.premium`)}):(0,V.jsx)(G,{children:n(`account.notPremium`)}),r.Verified?(0,V.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,V.jsx)(G,{children:n(`account.notVerified`)}),y.Frozen?(0,V.jsx)(G,{tone:`danger`,children:n(`account.accountFrozen`)}):(0,V.jsx)(G,{children:n(`account.accountActive`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:n(`account.userID`),value:String(y.ID),mono:!0}),(0,V.jsx)(J,{label:n(`account.lastActive`),value:Be(r.LastSeenAt)||`-`}),(0,V.jsx)(J,{label:n(`account.premiumUntil`),value:y.PremiumUntil>0?Be(y.PremiumUntil):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.starsBalance`),value:`${r.StarsBalance} / ${r.StarsGranted?n(`account.startingGrantApplied`):n(`account.startingGrantPending`)}`}),(0,V.jsx)(J,{label:n(`common.updatedAt`),value:ze(y.UpdatedAt)||`-`}),(0,V.jsx)(J,{label:n(`account.activeSessions`),value:String(r.Authorizations.length)}),(0,V.jsx)(J,{label:n(`account.accountFlags`),value:`support=${r.Support} bot=${r.Bot}`}),(0,V.jsx)(J,{label:n(`account.restriction`),value:r.HasRestriction?r.Restriction.Reason||n(`account.restricted`):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeSince`),value:r.Restriction.Since?ze(r.Restriction.Since):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeUntil`),value:r.Restriction.Until?ze(r.Restriction.Until):n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.freezeAppealURL`),value:r.Restriction.AppealURL||n(`common.none`)}),(0,V.jsx)(J,{label:n(`account.createdAt`),value:ze(y.CreatedAt)||`-`})]}),r.About&&(0,V.jsx)(`p`,{className:`about-text`,children:r.About}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.authorizationsTitle`),text:n(`account.authorizationsCount`,{count:r.Authorizations.length})}),(0,V.jsx)(et,{rows:r.Authorizations,userID:y.ID,onDone:v})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,V.jsx)(qe,{rows:r.AuditLogs})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:n(`account.actionDock`)}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.freezeUntil`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.freezeUntilAria`),value:p,onChange:e=>m(e.target.value),type:`datetime-local`})]}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.freezeAppealURL`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.freezeAppealURLAria`),value:h,onChange:e=>_(e.target.value),type:`url`,placeholder:`https://...`})]}),(0,V.jsx)($e,{label:y.Frozen?n(`account.updateFreeze`):n(`account.freezeAccount`),icon:(0,V.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!0,freeze_until:new Date(p).toISOString(),freeze_appeal_url:h.trim()}),onDone:v}),y.Frozen&&(0,V.jsx)($e,{label:n(`account.unfreezeAccount`),icon:(0,V.jsx)(O,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:y.ID,frozen:!1}),onDone:v}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.premiumMonths`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.premiumMonthsAria`),value:l,onChange:e=>u(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,V.jsxs)(`div`,{className:`action-stack`,children:[(0,V.jsx)($e,{label:n(`account.setPremium`),icon:(0,V.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:W(l)}),onDone:v}),(0,V.jsx)($e,{label:n(`account.clearPremium`),icon:(0,V.jsx)(j,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:y.ID,months:0}),onDone:v}),(0,V.jsxs)(`label`,{className:`duration-field`,children:[(0,V.jsx)(`span`,{children:n(`account.starsAmount`)}),(0,V.jsx)(`input`,{"aria-label":n(`account.starsAmountAria`),value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`1000000000`})]}),(0,V.jsx)($e,{label:n(`account.grantStars`),icon:(0,V.jsx)(he,{size:15}),tone:`warn`,path:`/api/actions/grant-stars`,payload:()=>({user_id:y.ID,amount:W(d)}),onDone:v}),(0,V.jsx)($e,{label:r.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,V.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:y.ID,verified:!r.Verified}),onDone:v})]})]})})})}function nt(e){return new Date(e.getTime()-e.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function rt(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function it(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}function at({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeActiveUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_active_us`,String(c.beforeActiveUS)));try{let e=await x.accounts(t);s(e),l({beforeID:e.next_before_id,beforeActiveUS:e.next_before_active_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=rt(o?.rows??[]);return(0,V.jsxs)(He,{title:t(`account.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`account.recentActive`),actions:(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,V.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`account.currentPage`),value:String(o?.rows.length??0)}),(0,V.jsx)(q,{label:t(`account.onlineDevices`),value:String(h.devices)}),(0,V.jsx)(q,{label:t(`account.premium`),value:String(h.premium),tone:`good`}),(0,V.jsx)(q,{label:t(`account.frozen`),value:String(h.frozen),tone:h.frozen>0?`danger`:`neutral`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`account.searchPlaceholder`)})]}),(0,V.jsxs)(`label`,{className:`field-inline`,children:[(0,V.jsx)(`span`,{children:t(`common.limit`)}),(0,V.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`account.userID`)}),(0,V.jsx)(`th`,{children:t(`account.phone`)}),(0,V.jsx)(`th`,{children:t(`common.username`)}),(0,V.jsx)(`th`,{children:t(`common.name`)}),(0,V.jsx)(`th`,{children:t(`common.device`)}),(0,V.jsx)(`th`,{children:t(`account.lastActive`)}),(0,V.jsx)(`th`,{children:t(`account.premium`)}),(0,V.jsx)(`th`,{children:t(`common.verified`)}),(0,V.jsx)(`th`,{children:t(`account.frozen`)}),(0,V.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Fe(n.Phone)}),(0,V.jsx)(`td`,{children:Ie(n.Username)}),(0,V.jsx)(`td`,{children:Le(n)}),(0,V.jsx)(`td`,{children:n.DeviceCount}),(0,V.jsx)(`td`,{children:ze(n.LastActiveAt)}),(0,V.jsx)(`td`,{children:n.PremiumUntil>0?(0,V.jsxs)(G,{tone:`good`,children:[t(`account.premium`),` `,Be(n.PremiumUntil)]}):(0,V.jsx)(G,{children:t(`common.none`)})}),(0,V.jsx)(`td`,{children:n.Verified?(0,V.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,V.jsx)(G,{children:t(`account.notVerified`)})}),(0,V.jsx)(`td`,{children:n.Frozen?(0,V.jsx)(G,{tone:`danger`,children:t(`account.frozen`)}):(0,V.jsx)(G,{children:t(`common.normal`)})}),(0,V.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,V.jsx)(Je,{colSpan:11})]})]})})]})}function ot({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.channel(e))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e]),a)return(0,V.jsx)(Ke,{children:a});if(!r)return(0,V.jsx)(Ye,{label:n(`channel.loadingDetail`)});let c=r.Channel;return(0,V.jsx)(He,{title:`${Re(c,n)} #${c.ID}`,eyebrow:n(`channel.detailProfile`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,V.jsx)(M,{size:15}),` `,n(`common.backToList`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:c.Title||`-`}),(0,V.jsxs)(`div`,{className:`entity-subtitle`,children:[Ie(c.Username)||n(`account.noUsername`),` · `,n(`channel.creator`,{id:c.CreatorUserID})]})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[(0,V.jsx)(G,{children:Re(c,n)}),c.Verified?(0,V.jsx)(G,{tone:`good`,children:n(`common.verified`)}):(0,V.jsx)(G,{children:n(`account.notVerified`)}),c.Deleted?(0,V.jsx)(G,{tone:`danger`,children:n(`common.deleted`)}):(0,V.jsx)(G,{children:n(`common.valid`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:n(`channel.channelID`),value:String(c.ID),mono:!0}),(0,V.jsx)(J,{label:`access_hash`,value:String(c.AccessHash),mono:!0}),(0,V.jsx)(J,{label:n(`common.members`),value:`${c.ParticipantsCount} / ${n(`common.admins`)} ${c.AdminsCount}`}),(0,V.jsx)(J,{label:n(`channel.governance`),value:n(`channel.governanceValue`,{banned:c.BannedCount,kicked:c.KickedCount})}),(0,V.jsx)(J,{label:n(`channel.flags`),value:`broadcast=${c.Broadcast} megagroup=${c.Megagroup} forum=${c.Forum}`}),(0,V.jsx)(J,{label:`top / pinned / PTS`,value:`${c.TopMessageID} / ${c.PinnedMessageID} / ${c.PTS}`}),(0,V.jsx)(J,{label:n(`account.createdAt`),value:Be(c.Date)||`-`}),(0,V.jsx)(J,{label:n(`common.updatedAt`),value:ze(c.UpdatedAt)||`-`})]}),c.About&&(0,V.jsx)(`p`,{className:`about-text`,children:c.About}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,V.jsx)(qe,{rows:r.AuditLogs})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:n(`channel.rawRow`),text:n(`channel.rawRowText`)}),(0,V.jsx)(Xe,{value:r.ChannelJSON})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:n(`channel.actionDock`)}),(0,V.jsx)($e,{label:c.Verified?n(`channel.clearVerified`):n(`channel.setVerified`),icon:(0,V.jsx)(D,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:c.ID,verified:!c.Verified}),onDone:s})]})})})}function st({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({beforeID:0,beforeUpdatedUS:0}),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(e=!1){d(!0),p(``);let t=new URLSearchParams({limit:i});n.trim()?t.set(`q`,n.trim()):e&&(t.set(`before_id`,String(c.beforeID)),t.set(`before_updated_us`,String(c.beforeUpdatedUS)));try{let e=await x.channels(t);s(e),l({beforeID:e.next_before_id,beforeUpdatedUS:e.next_before_updated_us})}catch(e){p(b(e))}finally{d(!1)}}(0,g.useEffect)(()=>{m(!1)},[]);let h=it(o?.rows??[]);return(0,V.jsxs)(He,{title:t(`channel.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`channel.recentUpdated`),actions:(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(!1),disabled:u,children:[(0,V.jsx)(de,{size:15}),` `,t(`common.refresh`)]}),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`channel.currentPage`),value:String(o?.rows.length??0)}),(0,V.jsx)(q,{label:t(`channel.megagroups`),value:String(h.megagroups)}),(0,V.jsx)(q,{label:t(`channel.broadcasts`),value:String(h.broadcasts)}),(0,V.jsx)(q,{label:t(`channel.verifiedCount`),value:String(h.verified),tone:`good`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`channel.searchPlaceholder`)})]}),(0,V.jsxs)(`label`,{className:`field-inline`,children:[(0,V.jsx)(`span`,{children:t(`common.limit`)}),(0,V.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:u,children:[u?(0,V.jsx)(A,{size:15,className:`spin`}):(0,V.jsx)(B,{size:15}),` `,t(`common.search`)]}),o?.listing&&o.has_more&&(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),disabled:u,children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`channel.channelID`)}),(0,V.jsx)(`th`,{children:t(`channel.kind`)}),(0,V.jsx)(`th`,{children:t(`common.username`)}),(0,V.jsx)(`th`,{children:t(`channel.title`)}),(0,V.jsx)(`th`,{children:t(`common.members`)}),(0,V.jsx)(`th`,{children:t(`common.admins`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.verified`)}),(0,V.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Re(n,t)}),(0,V.jsx)(`td`,{children:Ie(n.Username)}),(0,V.jsx)(`td`,{children:n.Title}),(0,V.jsx)(`td`,{children:n.ParticipantsCount}),(0,V.jsx)(`td`,{children:n.AdminsCount}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.Verified?(0,V.jsx)(G,{tone:`good`,children:t(`common.verified`)}):(0,V.jsx)(G,{children:t(`account.notVerified`)})}),(0,V.jsx)(`td`,{children:ze(n.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,V.jsx)(Je,{colSpan:10})]})]})})]})}function ct({navigate:e}){let{t}=U();return(0,V.jsxs)(`div`,{className:`dashboard-layout`,children:[(0,V.jsxs)(`section`,{className:`overview-band`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:t(`dashboard.eyebrow`)}),(0,V.jsx)(`h2`,{children:t(`dashboard.title`)})]}),(0,V.jsxs)(`div`,{className:`overview-metrics`,children:[(0,V.jsx)(K,{label:t(`dashboard.readPath`),value:t(`dashboard.readPathValue`),tone:`neutral`}),(0,V.jsx)(K,{label:t(`dashboard.writePath`),value:`Admin API`,tone:`good`}),(0,V.jsx)(K,{label:t(`dashboard.executionPolicy`),value:t(`dashboard.dryRunFirst`),tone:`warn`})]})]}),(0,V.jsxs)(`div`,{className:`command-grid`,children:[(0,V.jsx)(lt,{icon:(0,V.jsx)(ve,{}),title:t(`route.accounts`),text:t(`dashboard.accountsText`),href:`/accounts`,navigate:e}),(0,V.jsx)(lt,{icon:(0,V.jsx)(pe,{}),title:t(`route.channels`),text:t(`dashboard.channelsText`),href:`/channels`,navigate:e}),(0,V.jsx)(lt,{icon:(0,V.jsx)(ce,{}),title:t(`route.messages`),text:t(`dashboard.messagesText`),href:`/messages`,navigate:e})]}),(0,V.jsxs)(`section`,{className:`work-strip`,children:[(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(k,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.dryRun`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(ae,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.token`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(L,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.pagination`)})]}),(0,V.jsxs)(`div`,{className:`strip-item`,children:[(0,V.jsx)(te,{size:16}),(0,V.jsx)(`span`,{children:t(`dashboard.strip.snapshot`)})]})]})]})}function lt({icon:e,title:t,text:n,href:r,navigate:i}){return(0,V.jsxs)(je,{className:`launcher`,href:r,navigate:i,children:[(0,V.jsx)(`span`,{className:`launcher-icon`,children:e}),(0,V.jsxs)(`span`,{className:`launcher-copy`,children:[(0,V.jsx)(`strong`,{children:t}),(0,V.jsx)(`span`,{children:n})]}),(0,V.jsx)(I,{size:16})]})}function ut({channelID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.groupMessage(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,V.jsx)(Ke,{children:o});if(!i)return(0,V.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,V.jsx)(He,{title:r(`messages.groupDetailTitle`,{id:l.ID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,V.jsx)(M,{size:15}),` `,r(`messages.backGroup`)]}),children:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:r(`messages.channelGroupTitle`,{id:l.ChannelID})}),(0,V.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.SenderUserID,date:Be(l.Date)})})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,V.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,V.jsx)(G,{children:r(`common.survived`)}),l.Pinned&&(0,V.jsx)(G,{tone:`warn`,children:r(`messages.pinned`)}),l.Post&&(0,V.jsx)(G,{children:r(`messages.channelPost`)}),(0,V.jsxs)(G,{children:[`pts `,l.PTS]})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:r(`common.messageId`),value:String(l.ID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.channelGroup`),value:String(l.ChannelID),mono:!0}),(0,V.jsx)(J,{label:`From Peer`,value:`${l.FromPeerType}:${l.FromPeerID}`,mono:!0}),(0,V.jsx)(J,{label:r(`common.views`),value:String(l.ViewsCount)})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelMessageRow`),text:r(`messages.channelMessagesSnapshot`)}),(0,V.jsx)(Xe,{value:i.MessageJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelRow`),text:r(`messages.channelSnapshot`)}),(0,V.jsx)(Xe,{value:i.ChannelJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.channelUpdateEvents`),text:r(`messages.channelEventsSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.count`)}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.messageId`)}),(0,V.jsx)(`th`,{children:r(`common.sender`)}),(0,V.jsx)(`th`,{children:r(`common.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.PTSCount}),(0,V.jsx)(`td`,{children:e.Type}),(0,V.jsx)(`td`,{children:e.MessageID}),(0,V.jsx)(`td`,{children:e.SenderUserID}),(0,V.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),i.UpdateEvents.length===0&&(0,V.jsx)(Je,{colSpan:6})]})]})})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.eventJson`)}),(0,V.jsxs)(`div`,{className:`raw-grid`,children:[i.UpdateEvents.map(e=>(0,V.jsx)(Xe,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),i.UpdateEvents.length===0&&(0,V.jsx)(`div`,{className:`empty-panel`,children:r(`common.noResults`)})]})]})]})})}function dt({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.accounts(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,V.jsxs)(`div`,{className:`entity-picker`,children:[(0,V.jsxs)(`div`,{className:`picker-head`,children:[(0,V.jsx)(`span`,{children:e}),t?(0,V.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,V.jsx)(ye,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,V.jsxs)(`div`,{className:`selected-entity`,children:[(0,V.jsx)(P,{size:15}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:Le(t)}),(0,V.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,V.jsx)(`span`,{children:Ie(t.Username)||Fe(t.Phone)||`-`})]}):null,(0,V.jsxs)(`div`,{className:`picker-search`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.userPlaceholder`)}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,V.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,V.jsx)(`div`,{className:`picker-error`,children:u}),(0,V.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,V.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,V.jsx)(`span`,{className:`mono`,children:e.ID}),(0,V.jsx)(`strong`,{children:Le(e)}),(0,V.jsx)(`span`,{children:Ie(e.Username)||Fe(e.Phone)||`-`}),e.Verified?(0,V.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,V.jsx)(G,{children:r(`picker.regular`)})]},e.ID)),o.length===0&&!c?(0,V.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function ft({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await x.channels(e)).rows)}catch(e){d(b(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,V.jsxs)(`div`,{className:`entity-picker`,children:[(0,V.jsxs)(`div`,{className:`picker-head`,children:[(0,V.jsx)(`span`,{children:e}),t?(0,V.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,V.jsx)(ye,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,V.jsxs)(`div`,{className:`selected-entity`,children:[(0,V.jsx)(P,{size:15}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:t.Title||`-`}),(0,V.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,V.jsx)(`span`,{children:Ie(t.Username)||Re(t,r)})]}):null,(0,V.jsxs)(`div`,{className:`picker-search`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.channelPlaceholder`)}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,V.jsx)(A,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,V.jsx)(`div`,{className:`picker-error`,children:u}),(0,V.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,V.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,V.jsx)(`span`,{className:`mono`,children:e.ID}),(0,V.jsx)(`strong`,{children:e.Title||`-`}),(0,V.jsx)(`span`,{children:Ie(e.Username)||Re(e,r)}),e.Verified?(0,V.jsx)(G,{tone:`good`,children:r(`picker.verified`)}):(0,V.jsx)(G,{children:Re(e,r)})]},e.ID)),o.length===0&&!c?(0,V.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function pt({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`100`),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``);async function m(e=!1){if(p(``),!n){p(t(`messages.selectChannel`));return}let r=new URLSearchParams({channel_id:String(n.ID),limit:c});if(e&&u?.rows.length){let e=u.rows[u.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.ID)),a(String(e.Date)),s(String(e.ID))}else i&&r.set(`before_date`,i),o&&r.set(`before_id`,o);try{d(await x.groupMessages(r))}catch(e){p(b(e))}}function h(e){r(e),a(``),s(``),d(null)}let _=u?.rows??[];return(0,V.jsxs)(He,{title:t(`messages.groupTitle`),eyebrow:t(`messages.groupEyebrow`),children:[f&&(0,V.jsx)(Ke,{children:f}),(0,V.jsxs)(Ue,{children:[(0,V.jsx)(`div`,{className:`message-selector-grid single`,children:(0,V.jsx)(ft,{label:t(`messages.channelGroup`),value:n,onChange:h})}),(0,V.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,V.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,V.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,V.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,V.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),_.length?(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`messages.currentPage`),value:String(_.length)}),(0,V.jsx)(q,{label:t(`messages.mediaCount`),value:String(_.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,V.jsx)(q,{label:t(`messages.channelPosts`),value:String(_.filter(e=>e.Post).length)}),(0,V.jsx)(q,{label:t(`messages.channelGroup`),value:n?`${n.Title||Re(n,t)} (${n.ID})`:`-`})]}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`common.messageId`)}),(0,V.jsx)(`th`,{children:t(`common.time`)}),(0,V.jsx)(`th`,{children:t(`common.sender`)}),(0,V.jsx)(`th`,{children:`From Peer`}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.views`)}),(0,V.jsx)(`th`,{children:t(`common.status`)}),(0,V.jsx)(`th`,{children:t(`messages.body`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[_.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.ID}),(0,V.jsx)(`td`,{children:Be(n.Date)}),(0,V.jsx)(`td`,{className:`mono`,children:n.SenderUserID}),(0,V.jsxs)(`td`,{className:`mono`,children:[n.FromPeerType,`:`,n.FromPeerID]}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.ViewsCount}),(0,V.jsx)(`td`,{children:n.Deleted?(0,V.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):n.Pinned?(0,V.jsx)(G,{tone:`warn`,children:t(`messages.pinned`)}):(0,V.jsx)(G,{children:t(`common.survived`)})}),(0,V.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${n.ChannelID}&msg_id=${n.ID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},`${n.ChannelID}-${n.ID}`)),_.length===0&&(0,V.jsx)(Je,{colSpan:9})]})]})})]})}function mt({ownerUserID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await x.message(e,t))}catch(e){s(b(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,V.jsx)(Ke,{children:o});if(!i)return(0,V.jsx)(Ye,{label:r(`common.loading`)});let l=i.Message;return(0,V.jsx)(He,{title:r(`messages.privateDetailTitle`,{id:l.BoxID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,V.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,V.jsx)(M,{size:15}),` `,r(`messages.backPrivate`)]}),children:(0,V.jsx)(We,{main:(0,V.jsxs)(`div`,{className:`stacked-sections`,children:[(0,V.jsxs)(`section`,{className:`entity-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`entity-title`,children:r(`messages.ownerPeerTitle`,{owner:l.OwnerUserID,peer:l.PeerID})}),(0,V.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.FromUserID,date:Be(l.Date)})})]}),(0,V.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,V.jsx)(G,{tone:`danger`,children:r(`common.deleted`)}):(0,V.jsx)(G,{children:r(`common.survived`)}),(0,V.jsxs)(G,{children:[`pts `,l.PTS]}),(0,V.jsx)(G,{children:l.Outgoing?r(`messages.outgoing`):r(`messages.incoming`)})]})]}),(0,V.jsxs)(`div`,{className:`summary-grid`,children:[(0,V.jsx)(J,{label:r(`messages.boxID`),value:String(l.BoxID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.privateMessageID`),value:String(l.PrivateMessageID),mono:!0}),(0,V.jsx)(J,{label:r(`messages.messageSender`),value:String(l.MessageSenderID),mono:!0}),(0,V.jsx)(J,{label:r(`common.time`),value:Be(l.Date)})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.messageBox`),text:r(`messages.messageBoxesSnapshot`)}),(0,V.jsx)(Xe,{value:i.MessageJSON})]}),(0,V.jsxs)(`div`,{className:`raw-grid`,children:[(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.dialogRow`),text:r(`messages.dialogSnapshot`)}),(0,V.jsx)(Xe,{value:i.DialogJSON})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.privateRow`),text:r(`messages.privateSnapshot`)}),(0,V.jsx)(Xe,{value:i.PrivateJSON})]})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.userUpdateEvents`),text:r(`messages.userEventsSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.count`)}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.time`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.PTSCount}),(0,V.jsx)(`td`,{children:e.Type}),(0,V.jsx)(`td`,{children:Be(e.Date)})]},`${e.PTS}-${e.Type}`)),i.UpdateEvents.length===0&&(0,V.jsx)(Je,{colSpan:4})]})]})})]}),(0,V.jsxs)(`section`,{className:`section-block`,children:[(0,V.jsx)(Ge,{title:r(`messages.dispatchOutbox`),text:r(`messages.outboxSource`)}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:`ID`}),(0,V.jsx)(`th`,{children:r(`account.userID`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:r(`common.type`)}),(0,V.jsx)(`th`,{children:r(`common.status`)}),(0,V.jsx)(`th`,{children:r(`messages.attempts`)}),(0,V.jsx)(`th`,{children:r(`common.updatedAt`)})]})}),(0,V.jsxs)(`tbody`,{children:[i.Outbox.map(e=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{children:e.ID}),(0,V.jsx)(`td`,{children:e.TargetUserID}),(0,V.jsx)(`td`,{children:e.PTS}),(0,V.jsx)(`td`,{children:e.EventType}),(0,V.jsx)(`td`,{children:e.Status}),(0,V.jsx)(`td`,{children:e.Attempts}),(0,V.jsx)(`td`,{children:ze(e.UpdatedAt)})]},e.ID)),i.Outbox.length===0&&(0,V.jsx)(Je,{colSpan:7})]})]})})]})]}),side:(0,V.jsxs)(`section`,{className:`action-dock`,children:[(0,V.jsx)(`div`,{className:`dock-title`,children:r(`common.operations`)}),(0,V.jsx)($e,{label:r(`messages.deleteThis`),icon:(0,V.jsx)(ge,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:l.OwnerUserID,peer_id:l.PeerID,ids:[l.BoxID],revoke:!0}),onDone:c})]})})})}function ht({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`100`),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!0),[_,v]=(0,g.useState)(!1),[y,S]=(0,g.useState)(``),[C,w]=(0,g.useState)(`1`),[T,E]=(0,g.useState)(null),[D,O]=(0,g.useState)(``);async function k(e=!1){if(O(``),!n||!i){O(t(`messages.selectPrivatePeers`));return}let r=new URLSearchParams({owner_user_id:String(n.ID),peer_id:String(i.ID),limit:u});if(e&&T?.rows.length){let e=T.rows[T.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.BoxID)),s(String(e.Date)),l(String(e.BoxID))}else o&&r.set(`before_date`,o),c&&r.set(`before_id`,c);try{E(await x.messages(r))}catch(e){O(b(e))}}function A(e){r(e),s(``),l(``),E(null)}function j(e){a(e),s(``),l(``),E(null)}return(0,V.jsxs)(He,{title:t(`messages.privateTitle`),eyebrow:t(`messages.privateEyebrow`),children:[D&&(0,V.jsx)(Ke,{children:D}),(0,V.jsxs)(Ue,{children:[(0,V.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,V.jsx)(dt,{label:t(`messages.ownerUser`),value:n,onChange:A}),(0,V.jsx)(dt,{label:t(`messages.peerUser`),value:i,onChange:j})]}),(0,V.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),k(!1)},children:[(0,V.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,V.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,V.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,V.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,V.jsx)(B,{size:15}),` `,t(`messages.searchMessages`)]}),T?.rows.length?(0,V.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>k(!0),children:[(0,V.jsx)(I,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,V.jsxs)(`div`,{className:`metric-row`,children:[(0,V.jsx)(q,{label:t(`messages.currentPage`),value:String(T?.rows.length??0)}),(0,V.jsx)(q,{label:t(`messages.deleted`),value:String((T?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,V.jsx)(q,{label:t(`messages.outgoing`),value:String((T?.rows??[]).filter(e=>e.Outgoing).length)}),(0,V.jsx)(q,{label:t(`messages.ownerPeer`),value:n&&i?`${Le(n)} / ${Le(i)}`:`-`})]}),(0,V.jsxs)(`div`,{className:`operation-row`,children:[(0,V.jsxs)(`div`,{className:`operation-box`,children:[(0,V.jsxs)(`div`,{className:`operation-title`,children:[(0,V.jsx)(ge,{size:15}),` `,t(`messages.deleteSelected`)]}),(0,V.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:t(`messages.idsPlaceholder`)}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,V.jsx)($e,{path:`/api/actions/delete-messages`,label:t(`messages.previewDelete`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,ids:Ve(f,t(`messages.msgIDsInvalid`)),revoke:m})})]}),(0,V.jsxs)(`div`,{className:`operation-box`,children:[(0,V.jsxs)(`div`,{className:`operation-title`,children:[(0,V.jsx)(ie,{size:15}),` `,t(`messages.clearHistory`)]}),(0,V.jsx)(`input`,{value:y,onChange:e=>S(e.target.value),placeholder:t(`messages.maxIDPlaceholder`)}),(0,V.jsx)(`input`,{value:C,onChange:e=>w(e.target.value),placeholder:t(`messages.maxBatchesPlaceholder`)}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,V.jsxs)(`label`,{className:`checkline`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),` `,t(`messages.justClear`)]}),(0,V.jsx)($e,{path:`/api/actions/delete-history`,label:t(`messages.previewClearHistory`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,max_id:W(y),max_batches:W(C),just_clear:_,revoke:m})})]})]}),(0,V.jsx)(`div`,{className:`table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:t(`common.messageId`)}),(0,V.jsx)(`th`,{children:t(`common.time`)}),(0,V.jsx)(`th`,{children:t(`common.sender`)}),(0,V.jsx)(`th`,{children:t(`messages.direction`)}),(0,V.jsx)(`th`,{children:`PTS`}),(0,V.jsx)(`th`,{children:t(`common.status`)}),(0,V.jsx)(`th`,{children:t(`messages.body`)}),(0,V.jsx)(`th`,{})]})}),(0,V.jsxs)(`tbody`,{children:[T?.rows.map(n=>(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`td`,{className:`mono`,children:n.BoxID}),(0,V.jsx)(`td`,{children:Be(n.Date)}),(0,V.jsx)(`td`,{className:`mono`,children:n.FromUserID}),(0,V.jsx)(`td`,{children:n.Outgoing?t(`messages.outgoing`):t(`messages.incoming`)}),(0,V.jsx)(`td`,{children:n.PTS}),(0,V.jsx)(`td`,{children:n.Deleted?(0,V.jsx)(G,{tone:`danger`,children:t(`common.deleted`)}):(0,V.jsx)(G,{children:t(`common.survived`)})}),(0,V.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${n.OwnerUserID}&msg_id=${n.BoxID}`),children:[t(`common.detail`),` `,(0,V.jsx)(I,{size:14})]})})]},`${n.OwnerUserID}-${n.BoxID}`)),(!T||T.rows.length===0)&&(0,V.jsx)(Je,{colSpan:8})]})]})})]})}var gt=c(o(((e,t)=>{typeof document<`u`&&typeof navigator<`u`&&(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.lottie=r())})(e,(function(){var n=``,r=!1,i=-999999,a=function(e){r=!!e},o=function(){return r},s=function(e){n=e},c=function(){return n};function l(e){return document.createElement(e)}function u(e,t){var n,r=e.length,i;for(n=0;n1?n[1]=1:n[1]<=0&&(n[1]=0),L(n[0],n[1],n[2])}function te(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[2]+=t,n[2]>1?n[2]=1:n[2]<0&&(n[2]=0),L(n[0],n[1],n[2])}function ne(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[0]+=t/360,n[0]>1?--n[0]:n[0]<0&&(n[0]+=1),L(n[0],n[1],n[2])}(function(){var e=[],t,n;for(t=0;t<256;t+=1)n=t.toString(16),e[t]=n.length===1?`0`+n:n;return function(t,n,r){return t<0&&(t=0),n<0&&(n=0),r<0&&(r=0),`#`+e[t]+e[n]+e[r]}})();var re=function(e){g=!!e},ie=function(){return g},ae=function(e){_=e},oe=function(){return _},se=function(){return v},ce=function(e){E=e},le=function(){return E},ue=function(e){y=e};function z(e){return document.createElementNS(`http://www.w3.org/2000/svg`,e)}function de(e){"@babel/helpers - typeof";return de=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},de(e)}var B=function(){var e=1,t=[],n,r,i={onmessage:function(){},postMessage:function(e){n({data:e})}},a={postMessage:function(e){i.onmessage({data:e})}};function s(e){if(window.Worker&&window.Blob&&o()){var t=new Blob([`var _workerSelf = self; self.onmessage = `,e.toString()],{type:`text/javascript`}),r=URL.createObjectURL(t);return new Worker(r)}return n=e,i}function c(){r||(r=s(function(e){function t(){function e(t,n){var o,s,c=t.length,l,u,d,f;for(s=0;s=0;--t)if(e[t].ty===`sh`)if(e[t].ks.k.i)a(e[t].ks.k);else for(o=e[t].ks.k.length,r=0;rn[0]?!0:n[0]>e[0]?!1:e[1]>n[1]?!0:n[1]>e[1]?!1:e[2]>n[2]?!0:n[2]>e[2]?!1:null}var s=function(){var e=[4,4,14];function t(e){var t=e.t.d;e.t.d={k:[{s:t,t:0}]}}function n(e){var n,r=e.length;for(n=0;n=0;--n)if(e[n].ty===`sh`)if(e[n].ks.k.i)e[n].ks.k.c=e[n].closed;else for(a=e[n].ks.k.length,i=0;i500)&&(this._imageLoaded(),clearInterval(n)),t+=1}.bind(this),50)}function a(t){var n=r(t,this.assetsPath,this.path),i=z(`image`);b?this.testImageLoaded(i):i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.setAttributeNS(`http://www.w3.org/1999/xlink`,`href`,n),this._elementHelper.append?this._elementHelper.append(i):this._elementHelper.appendChild(i);var a={img:i,assetData:t};return a}function o(t){var n=r(t,this.assetsPath,this.path),i=l(`img`);i.crossOrigin=`anonymous`,i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.src=n;var a={img:i,assetData:t};return a}function s(e){var t={assetData:e},n=r(e,this.assetsPath,this.path);return B.loadData(n,function(e){t.img=e,this._footageLoaded()}.bind(this),function(){t.img={},this._footageLoaded()}.bind(this)),t}function c(e,t){this.imagesLoadedCb=t;var n,r=e.length;for(n=0;nthis.animationData.op&&(this.animationData.op=e.op,this.totalFrames=Math.floor(e.op-this.animationData.ip));var t=this.animationData.layers,n,r=t.length,i=e.layers,a,o=i.length;for(a=0;athis.timeCompleted&&(this.currentFrame=this.timeCompleted),this.trigger(`enterFrame`),this.renderFrame(),this.trigger(`drawnFrame`)},V.prototype.renderFrame=function(){if(!(this.isLoaded===!1||!this.renderer))try{this.expressionsPlugin&&this.expressionsPlugin.resetFrame(),this.renderer.renderFrame(this.currentFrame+this.firstFrame)}catch(e){this.triggerRenderFrameError(e)}},V.prototype.play=function(e){e&&this.name!==e||this.isPaused===!0&&(this.isPaused=!1,this.trigger(`_play`),this.audioController.resume(),this._idle&&(this._idle=!1,this.trigger(`_active`)))},V.prototype.pause=function(e){e&&this.name!==e||this.isPaused===!1&&(this.isPaused=!0,this.trigger(`_pause`),this._idle=!0,this.trigger(`_idle`),this.audioController.pause())},V.prototype.togglePause=function(e){e&&this.name!==e||(this.isPaused===!0?this.play():this.pause())},V.prototype.stop=function(e){e&&this.name!==e||(this.pause(),this.playCount=0,this._completedLoop=!1,this.setCurrentRawFrameValue(0))},V.prototype.getMarkerData=function(e){for(var t,n=0;n=this.totalFrames-1&&this.frameModifier>0?!this.loop||this.playCount===this.loop?this.checkSegments(t>this.totalFrames?t%this.totalFrames:0)||(n=!0,t=this.totalFrames-1):t>=this.totalFrames?(this.playCount+=1,this.checkSegments(t%this.totalFrames)||(this.setCurrentRawFrameValue(t%this.totalFrames),this._completedLoop=!0,this.trigger(`loopComplete`))):this.setCurrentRawFrameValue(t):t<0?this.checkSegments(t%this.totalFrames)||(this.loop&&!(this.playCount--<=0&&this.loop!==!0)?(this.setCurrentRawFrameValue(this.totalFrames+t%this.totalFrames),this._completedLoop?this.trigger(`loopComplete`):this._completedLoop=!0):(n=!0,t=0)):this.setCurrentRawFrameValue(t),n&&(this.setCurrentRawFrameValue(t),this.pause(),this.trigger(`complete`))}},V.prototype.adjustSegment=function(e,t){this.playCount=0,e[1]0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(-1)),this.totalFrames=e[0]-e[1],this.timeCompleted=this.totalFrames,this.firstFrame=e[1],this.setCurrentRawFrameValue(this.totalFrames-.001-t)):e[1]>e[0]&&(this.frameModifier<0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(1)),this.totalFrames=e[1]-e[0],this.timeCompleted=this.totalFrames,this.firstFrame=e[0],this.setCurrentRawFrameValue(.001+t)),this.trigger(`segmentStart`)},V.prototype.setSegment=function(e,t){var n=-1;this.isPaused&&(this.currentRawFrame+this.firstFramet&&(n=t-e)),this.firstFrame=e,this.totalFrames=t-e,this.timeCompleted=this.totalFrames,n!==-1&&this.goToAndStop(n,!0)},V.prototype.playSegments=function(e,t){if(t&&(this.segments.length=0),be(e[0])===`object`){var n,r=e.length;for(n=0;n=0;--n)t[n].animation.destroy(e)}function T(e,t,n){var r=[].concat([].slice.call(document.getElementsByClassName(`lottie`)),[].slice.call(document.getElementsByClassName(`bodymovin`))),i,a=r.length;for(i=0;i0?n=c:t=c;while(Math.abs(s)>a&&++l=i?g(e,d,t,n):f===0?d:h(e,a,a+c,t,n)}},e}(),Se=function(){function e(e){return e.concat(m(e.length))}return{double:e}}(),Ce=function(){return function(e,t,n){var r=0,i=e,a=m(i),o={newElement:s,release:c};function s(){var e;return r?(--r,e=a[r]):e=t(),e}function c(e){r===i&&(a=Se.double(a),i*=2),n&&n(e),a[r]=e,r+=1}return o}}(),U=function(){function e(){return{addedLength:0,percents:p(`float32`,le()),lengths:p(`float32`,le())}}return Ce(8,e)}(),we=function(){function e(){return{lengths:[],totalLength:0}}function t(e){var t,n=e.lengths.length;for(t=0;t-.001&&o<.001}function n(n,r,i,a,o,s,c,l,u){if(i===0&&s===0&&u===0)return t(n,r,a,o,c,l);var d=e.sqrt(e.pow(a-n,2)+e.pow(o-r,2)+e.pow(s-i,2)),f=e.sqrt(e.pow(c-n,2)+e.pow(l-r,2)+e.pow(u-i,2)),p=e.sqrt(e.pow(c-a,2)+e.pow(l-o,2)+e.pow(u-s,2)),m=d>f?d>p?d-f-p:p-f-d:p>f?p-f-d:f-d-p;return m>-1e-4&&m<1e-4}var r=function(){return function(e,t,n,r){var i=le(),a,o,s,c,l,u=0,d,f=[],p=[],m=U.newElement();for(s=n.length,a=0;ao?-1:1,l=!0;l;)if(r[a]<=o&&r[a+1]>o?(s=(o-r[a])/(r[a+1]-r[a]),l=!1):a+=c,a<0||a>=i-1){if(a===i-1)return n[a];l=!1}return n[a]+(n[a+1]-n[a])*s}function l(t,n,r,i,a,o){var s=c(a,o),l=1-s;return[e.round((l*l*l*t[0]+(s*l*l+l*s*l+l*l*s)*r[0]+(s*s*l+l*s*s+s*l*s)*i[0]+s*s*s*n[0])*1e3)/1e3,e.round((l*l*l*t[1]+(s*l*l+l*s*l+l*l*s)*r[1]+(s*s*l+l*s*s+s*l*s)*i[1]+s*s*s*n[1])*1e3)/1e3]}var u=p(`float32`,8);function d(t,n,r,i,a,o,s){a<0?a=0:a>1&&(a=1);var l=c(a,s);o=o>1?1:o;var d=c(o,s),f,p=t.length,m=1-l,h=1-d,g=m*m*m,_=l*m*m*3,v=l*l*m*3,y=l*l*l,b=m*m*h,x=l*m*h+m*l*h+m*m*d,S=l*l*h+m*l*d+l*m*d,C=l*l*d,w=m*h*h,T=l*h*h+m*d*h+m*h*d,E=l*d*h+m*d*d+l*h*d,D=l*d*d,O=h*h*h,k=d*h*h+h*d*h+h*h*d,A=d*d*h+h*d*d+d*h*d,j=d*d*d;for(f=0;f=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a=v||e=v?x.points.length-1:0;for(f=x.points[S].point.length,d=0;d=T&&C=v)r[0]=b[0],r[1]=b[1],r[2]=b[2];else if(e<=y)r[0]=c.s[0],r[1]=c.s[1],r[2]=c.s[2];else{var j=Me(c.s),M=Me(b),N=(e-y)/(v-y);je(r,Ae(j,M,N))}else for(a=0;a=v?m=1:e1e-6?(f=Math.acos(p),m=Math.sin(f),h=Math.sin((1-n)*f)/m,g=Math.sin(n*f)/m):(h=1-n,g=n),r[0]=h*i+g*c,r[1]=h*a+g*l,r[2]=h*o+g*u,r[3]=h*s+g*d,r}function je(e,t){var n=t[0],r=t[1],i=t[2],a=t[3],o=Math.atan2(2*r*a-2*n*i,1-2*r*r-2*i*i),s=Math.asin(2*n*r+2*i*a),c=Math.atan2(2*n*a-2*r*i,1-2*n*n-2*i*i);e[0]=o/D,e[1]=s/D,e[2]=c/D}function Me(e){var t=e[0]*D,n=e[1]*D,r=e[2]*D,i=Math.cos(t/2),a=Math.cos(n/2),o=Math.cos(r/2),s=Math.sin(t/2),c=Math.sin(n/2),l=Math.sin(r/2),u=i*a*o-s*c*l;return[s*c*o+i*a*l,s*a*o+i*c*l,i*c*o-s*a*l,u]}function Ne(){var e=this.comp.renderedFrame-this.offsetTime,t=this.keyframes[0].t-this.offsetTime,n=this.keyframes[this.keyframes.length-1].t-this.offsetTime;if(!(e===this._caching.lastFrame||this._caching.lastFrame!==De&&(this._caching.lastFrame>=n&&e>=n||this._caching.lastFrame=e&&(this._caching._lastKeyframeIndex=-1,this._caching.lastIndex=0);var r=this.interpolateValue(e,this._caching);this.pv=r}return this._caching.lastFrame=e,this.pv}function Pe(e){var t;if(this.propType===`unidimensional`)t=e*this.mult,Oe(this.v-t)>1e-5&&(this.v=t,this._mdf=!0);else for(var n=0,r=this.v.length;n1e-5&&(this.v[n]=t,this._mdf=!0),n+=1}function Fe(){if(!(this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)){if(this.lock){this.setVValue(this.pv);return}this.lock=!0,this._mdf=this._isFirstFrame;var e,t=this.effectsSequence.length,n=this.kf?this.pv:this.data.k;for(e=0;e=this._maxLength&&this.doubleArrayLength(),n){case`v`:a=this.v;break;case`i`:a=this.i;break;case`o`:a=this.o;break;default:a=[];break}(!a[r]||a[r]&&!i)&&(a[r]=He.newElement()),a[r][0]=e,a[r][1]=t},Ue.prototype.setTripleAt=function(e,t,n,r,i,a,o,s){this.setXYAt(e,t,`v`,o,s),this.setXYAt(n,r,`o`,o,s),this.setXYAt(i,a,`i`,o,s)},Ue.prototype.reverse=function(){var e=new Ue;e.setPathData(this.c,this._length);var t=this.v,n=this.o,r=this.i,i=0;this.c&&(e.setTripleAt(t[0][0],t[0][1],r[0][0],r[0][1],n[0][0],n[0][1],0,!1),i=1);var a=this._length-1,o=this._length,s;for(s=i;s=p[p.length-1].t-this.offsetTime)i=p[p.length-1].s?p[p.length-1].s[0]:p[p.length-2].e[0],o=!0;else{for(var m=r,h=p.length-1,g=!0,_,v,y;g&&(_=p[m],v=p[m+1],!(v.t-this.offsetTime>e));)m=v.t-this.offsetTime)d=1;else if(e<_.t-this.offsetTime)d=0;else{var b;y.__fnct?b=y.__fnct:(b=xe.getBezierEasing(_.o.x,_.o.y,_.i.x,_.i.y).get,y.__fnct=b),d=b((e-(_.t-this.offsetTime))/(v.t-this.offsetTime-(_.t-this.offsetTime)))}a=v.s?v.s[0]:_.e[0]}i=_.s[0]}for(l=t._length,u=i.i[0].length,n.lastIndex=r,s=0;sr&&t>r)||(this._caching.lastIndex=i0||e>-1e-6&&e<0?r(e*t)/t:e}function P(){var e=this.props,t=N(e[0]),n=N(e[1]),r=N(e[4]),i=N(e[5]),a=N(e[12]),o=N(e[13]);return`matrix(`+t+`,`+n+`,`+r+`,`+i+`,`+a+`,`+o+`)`}return function(){this.reset=i,this.rotate=a,this.rotateX=o,this.rotateY=s,this.rotateZ=c,this.skew=u,this.skewFromAxis=d,this.shear=l,this.scale=f,this.setTransform=m,this.translate=h,this.transform=g,this.multiply=_,this.applyToPoint=S,this.applyToX=C,this.applyToY=w,this.applyToZ=T,this.applyToPointArray=A,this.applyToTriplePoints=k,this.applyToPointStringified=j,this.toCSS=M,this.to2dCSS=P,this.clone=b,this.cloneFromProps=x,this.equals=y,this.inversePoints=O,this.inversePoint=D,this.getInverseMatrix=E,this._t=this.transform,this.isIdentity=v,this._identity=!0,this._identityCalculated=!1,this.props=p(`float32`,16),this.reset()}}();function q(e){"@babel/helpers - typeof";return q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},q(e)}var J={},qe=`__[STANDALONE]__`,Je=`__[ANIMATIONDATA]__`,Ye=``;function Xe(e){s(e)}function Ze(){qe===!0?H.searchAnimations(Je,qe,Ye):H.searchAnimations()}function Qe(e){re(e)}function $e(e){ue(e)}function et(e){return qe===!0&&(e.animationData=JSON.parse(Je)),H.loadAnimation(e)}function tt(e){if(typeof e==`string`)switch(e){case`high`:ce(200);break;default:case`medium`:ce(50);break;case`low`:ce(10);break}else!isNaN(e)&&e>1&&ce(e)}function nt(){return typeof navigator<`u`}function rt(e,t){e===`expressions`&&ae(t)}function it(e){switch(e){case`propertyFactory`:return W;case`shapePropertyFactory`:return G;case`matrix`:return K;default:return null}}J.play=H.play,J.pause=H.pause,J.setLocationHref=Xe,J.togglePause=H.togglePause,J.setSpeed=H.setSpeed,J.setDirection=H.setDirection,J.stop=H.stop,J.searchAnimations=Ze,J.registerAnimation=H.registerAnimation,J.loadAnimation=et,J.setSubframeRendering=Qe,J.resize=H.resize,J.goToAndStop=H.goToAndStop,J.destroy=H.destroy,J.setQuality=tt,J.inBrowser=nt,J.installPlugin=rt,J.freeze=H.freeze,J.unfreeze=H.unfreeze,J.setVolume=H.setVolume,J.mute=H.mute,J.unmute=H.unmute,J.getRegisteredAnimations=H.getRegisteredAnimations,J.useWebWorker=a,J.setIDPrefix=$e,J.__getFactory=it,J.version=`5.13.0`;function at(){document.readyState===`complete`&&(clearInterval(ut),Ze())}function ot(e){for(var t=st.split(`&`),n=0;n=1?a.push({s:e-1,e:t-1}):(a.push({s:e,e:1}),a.push({s:0,e:t-1}));var o=[],s,c=a.length,l;for(s=0;sr+n)){var u=l.s*i<=r?0:(l.s*i-r)/n,d=l.e*i>=r+n?1:(l.e*i-r)/n;o.push([u,d])}return o.length||o.push([0,0]),o},pt.prototype.releasePathsData=function(e){var t,n=e.length;for(t=0;t1?1+r:this.s.v<0?0+r:this.s.v+r,n=this.e.v>1?1+r:this.e.v<0?0+r:this.e.v+r,t>n){var i=t;t=n,n=i}t=Math.round(t*1e4)*1e-4,n=Math.round(n*1e4)*1e-4,this.sValue=t,this.eValue=n}else t=this.sValue,n=this.eValue;var a,o,s=this.shapes.length,c,l,u,d,f,p=0;if(n===t)for(o=0;o=0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;c=1?m.push({s:h.totalShapeLength*(_-1),e:h.totalShapeLength*(v-1)}):(m.push({s:h.totalShapeLength*_,e:h.totalShapeLength}),m.push({s:0,e:h.totalShapeLength*(v-1)}));var x=this.addShapes(h,m[0]);if(m[0].s!==m[0].e){if(m.length>1)if(h.shape.paths.shapes[h.shape.paths._length-1].c){var S=x.pop();this.addPaths(x,g),x=this.addShapes(h,m[1],S)}else this.addPaths(x,g),x=this.addShapes(h,m[1]);this.addPaths(x,g)}}h.shape.paths=g}}else if(this._mdf)for(o=0;ot.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=Ee.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=this.p.keyframes[this.p.keyframes.length-1].t?(r=this.p.getValueAtTime(this.p.keyframes[this.p.keyframes.length-1].t/n,0),i=this.p.getValueAtTime((this.p.keyframes[this.p.keyframes.length-1].t-.05)/n,0)):(r=this.p.pv,i=this.p.getValueAtTime((this.p._caching.lastFrame+this.p.offsetTime-.01)/n,this.p.offsetTime));else if(this.px&&this.px.keyframes&&this.py.keyframes&&this.px.getValueAtTime&&this.py.getValueAtTime){r=[],i=[];var a=this.px,o=this.py;a._caching.lastFrame+a.offsetTime<=a.keyframes[0].t?(r[0]=a.getValueAtTime((a.keyframes[0].t+.01)/n,0),r[1]=o.getValueAtTime((o.keyframes[0].t+.01)/n,0),i[0]=a.getValueAtTime(a.keyframes[0].t/n,0),i[1]=o.getValueAtTime(o.keyframes[0].t/n,0)):a._caching.lastFrame+a.offsetTime>=a.keyframes[a.keyframes.length-1].t?(r[0]=a.getValueAtTime(a.keyframes[a.keyframes.length-1].t/n,0),r[1]=o.getValueAtTime(o.keyframes[o.keyframes.length-1].t/n,0),i[0]=a.getValueAtTime((a.keyframes[a.keyframes.length-1].t-.01)/n,0),i[1]=o.getValueAtTime((o.keyframes[o.keyframes.length-1].t-.01)/n,0)):(r=[a.pv,o.pv],i[0]=a.getValueAtTime((a._caching.lastFrame+a.offsetTime-.01)/n,a.offsetTime),i[1]=o.getValueAtTime((o._caching.lastFrame+o.offsetTime-.01)/n,o.offsetTime))}else i=e,r=i;this.v.rotate(-Math.atan2(r[1]-i[1],r[0]-i[0]))}this.data.p&&this.data.p.s?this.data.p.z?this.v.translate(this.px.v,this.py.v,-this.pz.v):this.v.translate(this.px.v,this.py.v,0):this.v.translate(this.p.v[0],this.p.v[1],-this.p.v[2])}this.frameId=this.elem.globalData.frameId}}function r(){if(this.appliedTransformations=0,this.pre.reset(),!this.a.effectsSequence.length)this.pre.translate(-this.a.v[0],-this.a.v[1],this.a.v[2]),this.appliedTransformations=1;else return;if(!this.s.effectsSequence.length)this.pre.scale(this.s.v[0],this.s.v[1],this.s.v[2]),this.appliedTransformations=2;else return;if(this.sk)if(!this.sk.effectsSequence.length&&!this.sa.effectsSequence.length)this.pre.skewFromAxis(-this.sk.v,this.sa.v),this.appliedTransformations=3;else return;this.r?this.r.effectsSequence.length||(this.pre.rotate(-this.r.v),this.appliedTransformations=4):!this.rz.effectsSequence.length&&!this.ry.effectsSequence.length&&!this.rx.effectsSequence.length&&!this.or.effectsSequence.length&&(this.pre.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2]).rotateY(this.or.v[1]).rotateX(this.or.v[0]),this.appliedTransformations=4)}function i(){}function a(e){this._addDynamicProperty(e),this.elem.addDynamicProperty(e),this._isDirty=!0}function o(e,t,n){if(this.elem=e,this.frameId=-1,this.propType=`transform`,this.data=t,this.v=new K,this.pre=new K,this.appliedTransformations=0,this.initDynamicPropertyContainer(n||e),t.p&&t.p.s?(this.px=W.getProp(e,t.p.x,0,0,this),this.py=W.getProp(e,t.p.y,0,0,this),t.p.z&&(this.pz=W.getProp(e,t.p.z,0,0,this))):this.p=W.getProp(e,t.p||{k:[0,0,0]},1,0,this),t.rx){if(this.rx=W.getProp(e,t.rx,0,D,this),this.ry=W.getProp(e,t.ry,0,D,this),this.rz=W.getProp(e,t.rz,0,D,this),t.or.k[0].ti){var r,i=t.or.k.length;for(r=0;r0;)--n,this._elements.unshift(t[n]);this.dynamicProperties.length?this.k=!0:this.getValue(!0)},gt.prototype.resetElements=function(e){var t,n=e.length;for(t=0;t0?Math.floor(f):Math.ceil(f),h=this.pMatrix.props,g=this.rMatrix.props,_=this.sMatrix.props;this.pMatrix.reset(),this.rMatrix.reset(),this.sMatrix.reset(),this.tMatrix.reset(),this.matrix.reset();var v=0;if(f>0){for(;vm;)this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!0),--v;p&&(this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,-p,!0),v-=p)}r=this.data.m===1?0:this._currentCopies-1,i=this.data.m===1?1:-1,a=this._currentCopies;for(var y,b;a;){if(t=this.elemsData[r].it,n=t[t.length-1].transform.mProps.v.props,b=n.length,t[t.length-1].transform.mProps._mdf=!0,t[t.length-1].transform.op._mdf=!0,t[t.length-1].transform.op.v=this._currentCopies===1?this.so.v:this.so.v+(this.eo.v-this.so.v)*(r/(this._currentCopies-1)),v!==0){for((r!==0&&i===1||r!==this._currentCopies-1&&i===-1)&&this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!1),this.matrix.transform(g[0],g[1],g[2],g[3],g[4],g[5],g[6],g[7],g[8],g[9],g[10],g[11],g[12],g[13],g[14],g[15]),this.matrix.transform(_[0],_[1],_[2],_[3],_[4],_[5],_[6],_[7],_[8],_[9],_[10],_[11],_[12],_[13],_[14],_[15]),this.matrix.transform(h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8],h[9],h[10],h[11],h[12],h[13],h[14],h[15]),y=0;y0&&r<1?[t]:[]:[t-r,t+r].filter(function(e){return e>0&&e<1})},Tt.prototype.split=function(e){if(e<=0)return[wt(this.points[0]),this];if(e>=1)return[this,wt(this.points[this.points.length-1])];var t=xt(this.points[0],this.points[1],e),n=xt(this.points[1],this.points[2],e),r=xt(this.points[2],this.points[3],e),i=xt(t,n,e),a=xt(n,r,e),o=xt(i,a,e);return[new Tt(this.points[0],t,i,o,!0),new Tt(o,a,r,this.points[3],!0)]};function Et(e,t){var n=e.points[0][t],r=e.points[e.points.length-1][t];if(n>r){var i=r;r=n,n=i}for(var a=St(3*e.a[t],2*e.b[t],e.c[t]),o=0;o0&&a[o]<1){var s=e.point(a[o])[t];sr&&(r=s)}return{min:n,max:r}}Tt.prototype.bounds=function(){return{x:Et(this,0),y:Et(this,1)}},Tt.prototype.boundingBox=function(){var e=this.bounds();return{left:e.x.min,right:e.x.max,top:e.y.min,bottom:e.y.max,width:e.x.max-e.x.min,height:e.y.max-e.y.min,cx:(e.x.max+e.x.min)/2,cy:(e.y.max+e.y.min)/2}};function Dt(e,t,n){var r=e.boundingBox();return{cx:r.cx,cy:r.cy,width:r.width,height:r.height,bez:e,t:(t+n)/2,t1:t,t2:n}}function Ot(e){var t=e.bez.split(.5);return[Dt(t[0],e.t1,e.t),Dt(t[1],e.t,e.t2)]}function kt(e,t){return Math.abs(e.cx-t.cx)*2=a||e.width<=r&&e.height<=r&&t.width<=r&&t.height<=r){i.push([e.t,t.t]);return}var o=Ot(e),s=Ot(t);At(o[0],s[0],n+1,r,i,a),At(o[0],s[1],n+1,r,i,a),At(o[1],s[0],n+1,r,i,a),At(o[1],s[1],n+1,r,i,a)}}Tt.prototype.intersections=function(e,t,n){t===void 0&&(t=2),n===void 0&&(n=7);var r=[];return At(Dt(this,0,1),Dt(e,0,1),0,t,r,n),r},Tt.shapeSegment=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[t],e.o[t],e.i[n],e.v[n],!0)},Tt.shapeSegmentInverted=function(e,t){var n=(t+1)%e.length();return new Tt(e.v[n],e.i[n],e.o[t],e.v[t],!0)};function jt(e,t){return[e[1]*t[2]-e[2]*t[1],e[2]*t[0]-e[0]*t[2],e[0]*t[1]-e[1]*t[0]]}function Mt(e,t,n,r){var i=[e[0],e[1],1],a=[t[0],t[1],1],o=[n[0],n[1],1],s=[r[0],r[1],1],c=jt(jt(i,a),jt(o,s));return yt(c[2])?null:[c[0]/c[2],c[1]/c[2]]}function Y(e,t,n){return[e[0]+Math.cos(t)*n,e[1]-Math.sin(t)*n]}function Nt(e,t){return Math.hypot(e[0]-t[0],e[1]-t[1])}function Pt(e,t){return vt(e[0],t[0])&&vt(e[1],t[1])}function Ft(){}u([ft],Ft),Ft.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amplitude=W.getProp(e,t.s,0,null,this),this.frequency=W.getProp(e,t.r,0,null,this),this.pointsType=W.getProp(e,t.pt,0,null,this),this._isAnimated=this.amplitude.effectsSequence.length!==0||this.frequency.effectsSequence.length!==0||this.pointsType.effectsSequence.length!==0};function It(e,t,n,r,i,a,o){var s=n-Math.PI/2,c=n+Math.PI/2,l=t[0]+Math.cos(n)*r*i,u=t[1]-Math.sin(n)*r*i;e.setTripleAt(l,u,l+Math.cos(s)*a,u-Math.sin(s)*a,l+Math.cos(c)*o,u-Math.sin(c)*o,e.length())}function Lt(e,t){var n=[t[0]-e[0],t[1]-e[1]],r=-Math.PI*.5;return[Math.cos(r)*n[0]-Math.sin(r)*n[1],Math.sin(r)*n[0]+Math.cos(r)*n[1]]}function Rt(e,t){var n=t===0?e.length()-1:t-1,r=(t+1)%e.length(),i=e.v[n],a=e.v[r],o=Lt(i,a);return Math.atan2(0,1)-Math.atan2(o[1],o[0])}function zt(e,t,n,r,i,a,o){var s=Rt(t,n),c=t.v[n%t._length],l=t.v[n===0?t._length-1:n-1],u=t.v[(n+1)%t._length],d=a===2?Math.sqrt((c[0]-l[0])**2+(c[1]-l[1])**2):0,f=a===2?Math.sqrt((c[0]-u[0])**2+(c[1]-u[1])**2):0;It(e,t.v[n%t._length],s,o,r,f/((i+1)*2),d/((i+1)*2),a)}function Bt(e,t,n,r,i,a){for(var o=0;o1&&t.length>1&&(i=Wt(e[0],t[t.length-1]),i)?[[e[0].split(i[0])[0]],[t[t.length-1].split(i[1])[1]]]:[n,r]}function Kt(e){for(var t,n=1;n1&&(t=Gt(e[e.length-1],e[0]),e[e.length-1]=t[0],e[0]=t[1]),e}function qt(e,t){var n=e.inflectionPoints(),r,i,a,o;if(n.length===0)return[Ht(e,t)];if(n.length===1||vt(n[1],1))return a=e.split(n[0]),r=a[0],i=a[1],[Ht(r,t),Ht(i,t)];a=e.split(n[0]),r=a[0];var s=(n[1]-n[0])/(1-n[0]);return a=a[1].split(s),o=a[0],i=a[1],[Ht(r,t),Ht(o,t),Ht(i,t)]}function Jt(){}u([ft],Jt),Jt.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amount=W.getProp(e,t.a,0,null,this),this.miterLimit=W.getProp(e,t.ml,0,null,this),this.lineJoin=t.lj,this._isAnimated=this.amount.effectsSequence.length!==0},Jt.prototype.processPath=function(e,t,n,r){var i=We.newElement();i.c=e.c;var a=e.length();e.c||--a;var o,s,c,l=[];for(o=0;o=0;--o)c=Tt.shapeSegmentInverted(e,o),l.push(qt(c,t));l=Kt(l);var u=null,d=null;for(o=0;o0&&(o=!1),o){var u=l(`style`);u.setAttribute(`f-forigin`,n[r].fOrigin),u.setAttribute(`f-origin`,n[r].origin),u.setAttribute(`f-family`,n[r].fFamily),u.type=`text/css`,u.innerText=`@font-face {font-family: `+n[r].fFamily+`; font-style: normal; src: url('`+n[r].fPath+`');}`,t.appendChild(u)}}else if(n[r].fOrigin===`g`||n[r].origin===1){for(s=document.querySelectorAll(`link[f-forigin="g"], link[f-origin="1"]`),c=0;c=55296&&n<=56319){var r=e.charCodeAt(1);r>=56320&&r<=57343&&(t=(n-55296)*1024+r-56320+65536)}return t}function S(e,t){var n=e.toString(16)+t.toString(16);return d.indexOf(n)!==-1}function C(e){return e===s}function w(e){return e===o}function T(e){var t=x(e);return t>=c&&t<=u}function E(e){return T(e.substr(0,2))&&T(e.substr(2,2))}function D(e){return t.indexOf(e)!==-1}function O(e,t){var o=x(e.substr(t,2));if(o!==n)return!1;var s=0;for(t+=2;s<5;){if(o=x(e.substr(t,2)),oa)return!1;s+=1,t+=2}return x(e.substr(t,2))===r}function k(){this.isLoaded=!0}var A=function(){this.fonts=[],this.chars=null,this.typekitLoaded=0,this.isLoaded=!1,this._warned=!1,this.initTime=Date.now(),this.setIsLoadedBinded=this.setIsLoaded.bind(this),this.checkLoadedFontsBinded=this.checkLoadedFonts.bind(this)};return A.isModifier=S,A.isZeroWidthJoiner=C,A.isFlagEmoji=E,A.isRegionalCode=T,A.isCombinedCharacter=D,A.isRegionalFlag=O,A.isVariationSelector=w,A.BLACK_FLAG_CODE_POINT=n,A.prototype={addChars:_,addFonts:g,getCharData:v,getFontByName:b,measureText:y,checkLoadedFonts:m,setIsLoaded:k},A}();function Zt(e){this.animationData=e}Zt.prototype.getProp=function(e){return this.animationData.slots&&this.animationData.slots[e.sid]?Object.assign(e,this.animationData.slots[e.sid].p):e};function Qt(e){return new Zt(e)}function $t(){}$t.prototype={initRenderable:function(){this.isInRange=!1,this.hidden=!1,this.isTransparent=!1,this.renderableComponents=[]},addRenderableComponent:function(e){this.renderableComponents.indexOf(e)===-1&&this.renderableComponents.push(e)},removeRenderableComponent:function(e){this.renderableComponents.indexOf(e)!==-1&&this.renderableComponents.splice(this.renderableComponents.indexOf(e),1)},prepareRenderableFrame:function(e){this.checkLayerLimits(e)},checkTransparency:function(){this.finalTransform.mProp.o.v<=0?!this.isTransparent&&this.globalData.renderConfig.hideOnTransparent&&(this.isTransparent=!0,this.hide()):this.isTransparent&&(this.isTransparent=!1,this.show())},checkLayerLimits:function(e){this.data.ip-this.data.st<=e&&this.data.op-this.data.st>e?this.isInRange!==!0&&(this.globalData._mdf=!0,this._mdf=!0,this.isInRange=!0,this.show()):this.isInRange!==!1&&(this.globalData._mdf=!0,this.isInRange=!1,this.hide())},renderRenderable:function(){var e,t=this.renderableComponents.length;for(e=0;e.1)&&this.audio.seek(this._currentTime/this.globalData.frameRate):(this.audio.play(),this.audio.seek(this._currentTime/this.globalData.frameRate),this._isPlaying=!0))},hn.prototype.show=function(){},hn.prototype.hide=function(){this.audio.pause(),this._isPlaying=!1},hn.prototype.pause=function(){this.audio.pause(),this._isPlaying=!1,this._canPlay=!1},hn.prototype.resume=function(){this._canPlay=!0},hn.prototype.setRate=function(e){this.audio.rate(e)},hn.prototype.volume=function(e){this._volumeMultiplier=e,this._previousVolume=e*this._volume,this.audio.volume(this._previousVolume)},hn.prototype.getBaseElement=function(){return null},hn.prototype.destroy=function(){},hn.prototype.sourceRectAtTime=function(){},hn.prototype.initExpressions=function(){};function gn(){}gn.prototype.checkLayers=function(e){var t,n=this.layers.length,r;for(this.completeLayers=!0,t=n-1;t>=0;--t)this.elements[t]||(r=this.layers[t],r.ip-r.st<=e-this.layers[t].st&&r.op-r.st>e-this.layers[t].st&&this.buildItem(t)),this.completeLayers=this.elements[t]?this.completeLayers:!1;this.checkPendingElements()},gn.prototype.createItem=function(e){switch(e.ty){case 2:return this.createImage(e);case 0:return this.createComp(e);case 1:return this.createSolid(e);case 3:return this.createNull(e);case 4:return this.createShape(e);case 5:return this.createText(e);case 6:return this.createAudio(e);case 13:return this.createCamera(e);case 15:return this.createFootage(e);default:return this.createNull(e)}},gn.prototype.createCamera=function(){throw Error(`You're using a 3d camera. Try the html renderer.`)},gn.prototype.createAudio=function(e){return new hn(e,this.globalData,this)},gn.prototype.createFootage=function(e){return new mn(e,this.globalData,this)},gn.prototype.buildAllItems=function(){var e,t=this.layers.length;for(e=0;e0&&(this.maskElement.setAttribute(`id`,p),this.element.maskedElement.setAttribute(b,`url(`+c()+`#`+p+`)`),r.appendChild(this.maskElement)),this.viewData.length&&this.element.addRenderableComponent(this)}yn.prototype.getMaskProperty=function(e){return this.viewData[e].prop},yn.prototype.renderFrame=function(e){var t=this.element.finalTransform.mat,n,r=this.masksProperties.length;for(n=0;n1&&(r+=` C`+t.o[i-1][0]+`,`+t.o[i-1][1]+` `+t.i[0][0]+`,`+t.i[0][1]+` `+t.v[0][0]+`,`+t.v[0][1]),n.lastPath!==r){var o=``;n.elem&&(t.c&&(o=e.inv?this.solidPath+r:r),n.elem.setAttribute(`d`,o)),n.lastPath=r}},yn.prototype.destroy=function(){this.element=null,this.globalData=null,this.maskElement=null,this.data=null,this.masksProperties=null};var bn=function(){var e={};e.createFilter=t,e.createAlphaToLuminanceFilter=n;function t(e,t){var n=z(`filter`);return n.setAttribute(`id`,e),t!==!0&&(n.setAttribute(`filterUnits`,`objectBoundingBox`),n.setAttribute(`x`,`0%`),n.setAttribute(`y`,`0%`),n.setAttribute(`width`,`100%`),n.setAttribute(`height`,`100%`)),n}function n(){var e=z(`feColorMatrix`);return e.setAttribute(`type`,`matrix`),e.setAttribute(`color-interpolation-filters`,`sRGB`),e.setAttribute(`values`,`0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1`),e}return e}(),xn=function(){var e={maskType:!0,svgLumaHidden:!0,offscreenCanvas:typeof OffscreenCanvas<`u`};return(/MSIE 10/i.test(navigator.userAgent)||/MSIE 9/i.test(navigator.userAgent)||/rv:11.0/i.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent))&&(e.maskType=!1),/firefox/i.test(navigator.userAgent)&&(e.svgLumaHidden=!1),e}(),Sn={},Cn=`filter_result_`;function wn(e){var t,n=`SourceGraphic`,r=e.data.ef?e.data.ef.length:0,i=I(),a=bn.createFilter(i,!0),o=0;this.filters=[];var s;for(t=0;t=0&&(n=this.shapeModifiers[e].processShapes(this._isFirstFrame),!n);--e);}},searchProcessedElement:function(e){for(var t=this.processedElements,n=0,r=t.length;n.01)return!1;n+=1}return!0},zn.prototype.checkCollapsable=function(){if(this.o.length/2!=this.c.length/4)return!1;if(this.data.k.k[0].s)for(var e=0,t=this.data.k.k.length;e0;)c=r.transformers[g].mProps._mdf||c,--h,--g;if(c)for(h=f-r.styles[u].lvl,g=r.transformers.length-1;h>0;)m.multiply(r.transformers[g].mProps.v),--h,--g}else m=e;if(p=r.sh.paths,o=p._length,c){for(s=``,a=0;a=1?v=.99:v<=-1&&(v=-.99);var y=g*v,b=Math.cos(_+t.a.v)*y+a[0],x=Math.sin(_+t.a.v)*y+a[1];r.setAttribute(`fx`,b),r.setAttribute(`fy`,x),i&&!t.g._collapsable&&(t.of.setAttribute(`fx`,b),t.of.setAttribute(`fy`,x))}}}function u(e,t,n){var r=t.style,i=t.d;i&&(i._mdf||n)&&i.dashStr&&(r.pElem.setAttribute(`stroke-dasharray`,i.dashStr),r.pElem.setAttribute(`stroke-dashoffset`,i.dashoffset[0])),t.c&&(t.c._mdf||n)&&r.pElem.setAttribute(`stroke`,`rgb(`+C(t.c.v[0])+`,`+C(t.c.v[1])+`,`+C(t.c.v[2])+`)`),(t.o._mdf||n)&&r.pElem.setAttribute(`stroke-opacity`,t.o.v),(t.w._mdf||n)&&(r.pElem.setAttribute(`stroke-width`,t.w.v),r.msElem&&r.msElem.setAttribute(`stroke-width`,t.w.v))}return n}();function X(e,t,n){this.shapes=[],this.shapesData=e.shapes,this.stylesList=[],this.shapeModifiers=[],this.itemsData=[],this.processedElements=[],this.animatedContents=[],this.initElement(e,t,n),this.prevViewData=[]}u([fn,vn,Tn,An,En,pn,Dn],X),X.prototype.initSecondaryElement=function(){},X.prototype.identityMatrix=new K,X.prototype.buildExpressionInterface=function(){},X.prototype.createContent=function(){this.searchShapes(this.shapesData,this.itemsData,this.prevViewData,this.layerElement,0,[],!0),this.filterUniqueShapes()},X.prototype.filterUniqueShapes=function(){var e,t=this.shapes.length,n,r,i=this.stylesList.length,a,o=[],s=!1;for(r=0;r1&&s&&this.setShapesAsAnimated(o)}},X.prototype.setShapesAsAnimated=function(e){var t,n=e.length;for(t=0;t=0;--c){if(g=this.searchProcessedElement(e[c]),g?t[c]=n[g-1]:e[c]._render=o,e[c].ty===`fl`||e[c].ty===`st`||e[c].ty===`gf`||e[c].ty===`gs`||e[c].ty===`no`)g?t[c].style.closed=e[c].hd:t[c]=this.createStyleElement(e[c],i),e[c]._render&&t[c].style.pElem.parentNode!==r&&r.appendChild(t[c].style.pElem),f.push(t[c].style);else if(e[c].ty===`gr`){if(!g)t[c]=this.createGroupElement(e[c]);else for(d=t[c].it.length,u=0;u1,this.kf&&this.addEffect(this.getKeyframeValue.bind(this)),this.kf},qn.prototype.addEffect=function(e){this.effectsSequence.push(e),this.elem.addDynamicProperty(this)},qn.prototype.getValue=function(e){if(!((this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)&&!e)){this.currentData.t=this.data.d.k[this.keysIndex].s.t;var t=this.currentData,n=this.keysIndex;if(this.lock){this.setCurrentData(this.currentData);return}this.lock=!0,this._mdf=!1;var r,i=this.effectsSequence.length,a=e||this.data.d.k[this.keysIndex].s;for(r=0;rt);)n+=1;return this.keysIndex!==n&&(this.keysIndex=n),this.data.d.k[this.keysIndex].s},qn.prototype.buildFinalText=function(e){for(var t=[],n=0,r=e.length,i,a,o=!1,s=!1,c=``;n=55296&&i<=56319?Xt.isRegionalFlag(e,n)?c=e.substr(n,14):(a=e.charCodeAt(n+1),a>=56320&&a<=57343&&(Xt.isModifier(i,a)?(c=e.substr(n,2),o=!0):c=Xt.isFlagEmoji(e.substr(n,4))?e.substr(n,4):e.substr(n,2))):i>56319?(a=e.charCodeAt(n+1),Xt.isVariationSelector(i)&&(o=!0)):Xt.isZeroWidthJoiner(i)&&(o=!0,s=!0),o?(t[t.length-1]+=c,o=!1):t.push(c),n+=c.length;return t},qn.prototype.completeTextData=function(e){e.__complete=!0;var t=this.elem.globalData.fontManager,n=this.data,r=[],i,a,o,s=0,c,l=n.m.g,u=0,d=0,f=0,p=[],m=0,h=0,g,_,v=t.getFontByName(e.f),y,b=0,x=Yt(v);e.fWeight=x.weight,e.fStyle=x.style,e.finalSize=e.s,e.finalText=this.buildFinalText(e.t),a=e.finalText.length,e.finalLineHeight=e.lh;var S=e.tr/1e3*e.finalSize,C;if(e.sz)for(var w=!0,T=e.sz[0],E=e.sz[1],D,O;w;){O=this.buildFinalText(e.t),D=0,m=0,a=O.length,S=e.tr/1e3*e.finalSize;var k=-1;for(i=0;iT&&O[i]!==` `?(k===-1?a+=1:i=k,D+=e.finalLineHeight||e.finalSize*1.2,O.splice(i,+(k===i),`\r`),k=-1,m=0):(m+=b,m+=S);D+=v.ascent*e.finalSize/100,this.canResize&&e.finalSize>this.minimumFontSize&&Eh?m:h,m=-2*S,c=``,o=!0,f+=1):c=j,t.chars?(y=t.getCharData(j,v.fStyle,t.getFontByName(e.f).fFamily),b=o?0:y.w*e.finalSize/100):b=t.measureText(c,e.f,e.finalSize),j===` `?A+=b+S:(m+=b+S+A,A=0),r.push({l:b,an:b,add:u,n:o,anIndexes:[],val:c,line:f,animatorJustifyOffset:0}),l==2){if(u+=b,c===``||c===` `||i===a-1){for((c===``||c===` `)&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;s+=1,u=0}}else if(l==3){if(u+=b,c===``||i===a-1){for(c===``&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;u=0,s+=1}}else r[s].ind=s,r[s].extra=0,s+=1;if(e.l=r,h=m>h?m:h,p.push(m),e.sz)e.boxWidth=e.sz[0],e.justifyOffset=0;else switch(e.boxWidth=h,e.j){case 1:e.justifyOffset=-e.boxWidth;break;case 2:e.justifyOffset=-e.boxWidth/2;break;default:e.justifyOffset=0}e.lineWidths=p;var M=n.a,N,P;_=M.length;var F,I,L=[];for(g=0;g<_;g+=1){for(N=M[g],N.a.sc&&(e.strokeColorAnim=!0),N.a.sw&&(e.strokeWidthAnim=!0),(N.a.fc||N.a.fh||N.a.fs||N.a.fb)&&(e.fillColorAnim=!0),I=0,F=N.s.b,i=0;i0?i=this.ne.v/100:a=-this.ne.v/100,this.xe.v>0?o=1-this.xe.v/100:s=1+this.xe.v/100;var c=xe.getBezierEasing(i,a,o,s).get,l=0,u=this.finalS,d=this.finalE,f=this.data.sh;if(f===2)l=d===u?+(r>=d):e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===3)l=d===u?r>=d?0:1:1-e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===4)d===u?l=0:(l=e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l<.5?l*=2:l=1-2*(l-.5)),l=c(l);else if(f===5){if(d===u)l=0;else{var p=d-u;r=t(e(0,r+.5-u),d-u);var m=-p/2+r,h=p/2;l=Math.sqrt(1-m*m/(h*h))}l=c(l)}else f===6?(d===u?l=0:(r=t(e(0,r+.5-u),d-u),l=(1+Math.cos(Math.PI+Math.PI*2*r/(d-u)))/2),l=c(l)):(r>=n(u)&&(l=r-u<0?e(0,t(t(d,1)-(u-r),1)):e(0,t(d-r,1))),l=c(l));if(this.sm.v!==100){var g=this.sm.v*.01;g===0&&(g=1e-8);var _=.5-g*.5;l<_?l=0:(l=(l-_)/g,l>1&&(l=1))}return l*this.a.v},getValue:function(e){this.iterateDynamicProperties(),this._mdf=e||this._mdf,this._currentTextLength=this.elem.textProperty.currentData.l.length||0,e&&this.data.r===2&&(this.e.v=this._currentTextLength);var t=this.data.r===2?1:100/this.data.totalChars,n=this.o.v/t,r=this.s.v/t+n,i=this.e.v/t+n;if(r>i){var a=r;r=i,i=a}this.finalS=r,this.finalE=i}},u([Ve],r);function i(e,t,n){return new r(e,t,n)}return{getTextSelectorProp:i}}();function Yn(e,t,n){var r={propType:!1},i=W.getProp,a=t.a;this.a={r:a.r?i(e,a.r,0,D,n):r,rx:a.rx?i(e,a.rx,0,D,n):r,ry:a.ry?i(e,a.ry,0,D,n):r,sk:a.sk?i(e,a.sk,0,D,n):r,sa:a.sa?i(e,a.sa,0,D,n):r,s:a.s?i(e,a.s,1,.01,n):r,a:a.a?i(e,a.a,1,0,n):r,o:a.o?i(e,a.o,0,.01,n):r,p:a.p?i(e,a.p,1,0,n):r,sw:a.sw?i(e,a.sw,0,0,n):r,sc:a.sc?i(e,a.sc,1,0,n):r,fc:a.fc?i(e,a.fc,1,0,n):r,fh:a.fh?i(e,a.fh,0,0,n):r,fs:a.fs?i(e,a.fs,0,.01,n):r,fb:a.fb?i(e,a.fb,0,.01,n):r,t:a.t?i(e,a.t,0,0,n):r},this.s=Jn.getTextSelectorProp(e,t.s,n),this.s.t=t.s.t}function Xn(e,t,n){this._isFirstFrame=!0,this._hasMaskedPath=!1,this._frameId=-1,this._textData=e,this._renderType=t,this._elem=n,this._animatorsData=m(this._textData.a.length),this._pathData={},this._moreOptions={alignment:{}},this.renderedLetters=[],this.lettersChangedFlag=!1,this.initDynamicPropertyContainer(n)}Xn.prototype.searchProperties=function(){var e,t=this._textData.a.length,n,r=W.getProp;for(e=0;e=m+Se||!x?(T=(m+Se-g)/h.partialLength,ie=b.point[0]+(h.point[0]-b.point[0])*T,ae=b.point[1]+(h.point[1]-b.point[1])*T,a.translate(-n[0]*f[u].an*.005,-(n[1]*A)*.01),_=!1):x&&(g+=h.partialLength,v+=1,v>=x.length&&(v=0,y+=1,S[y]?x=S[y].points:D.v.c?(v=0,y=0,x=S[y].points):(g-=h.partialLength,x=null)),x&&(b=h,h=x[v],C=h.partialLength));re=f[u].an/2-f[u].add,a.translate(-re,0,0)}else re=f[u].an/2-f[u].add,a.translate(-re,0,0),a.translate(-n[0]*f[u].an*.005,-n[1]*A*.01,0);for(P=0;Pe?this.textSpans[e].span:z(s?`g`:`text`),b<=e){if(c.setAttribute(`stroke-linecap`,`butt`),c.setAttribute(`stroke-linejoin`,`round`),c.setAttribute(`stroke-miterlimit`,`4`),this.textSpans[e].span=c,s){var S=z(`g`);c.appendChild(S),this.textSpans[e].childSpan=S}this.textSpans[e].span=c,this.layerElement.appendChild(c)}c.style.display=`inherit`}if(l.reset(),d&&(o[e].n&&(f=-g,p+=n.yOffset,p+=+!!h,h=!1),this.applyTextPropertiesToMatrix(n,l,o[e].line,f,p),f+=o[e].l||0,f+=g),s){x=this.globalData.fontManager.getCharData(n.finalText[e],r.fStyle,this.globalData.fontManager.getFontByName(n.f).fFamily);var C;if(x.t===1)C=new ir(x.data,this.globalData,this);else{var w=Qn;x.data&&x.data.shapes&&(w=this.buildShapeData(x.data,n.finalSize)),C=new X(w,this.globalData,this)}if(this.textSpans[e].glyph){var T=this.textSpans[e].glyph;this.textSpans[e].childSpan.removeChild(T.layerElement),T.destroy()}this.textSpans[e].glyph=C,C._debug=!0,C.prepareFrame(0),C.renderFrame(),this.textSpans[e].childSpan.appendChild(C.layerElement),x.t===1&&this.textSpans[e].childSpan.setAttribute(`transform`,`scale(`+n.finalSize/100+`,`+n.finalSize/100+`)`)}else d&&c.setAttribute(`transform`,`translate(`+l.props[12]+`,`+l.props[13]+`)`),c.textContent=o[e].val,c.setAttributeNS(`http://www.w3.org/XML/1998/namespace`,`xml:space`,`preserve`)}d&&c&&c.setAttribute(`d`,u)}for(;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].prepareFrame(e-this.layers[t].st);if(this.globalData._mdf)for(t=0;t=0;--n)(this.completeLayers||this.elements[n])&&(this.elements[n].prepareFrame(this.renderedFrame-this.layers[n].st),this.elements[n]._mdf&&(this._mdf=!0))}},rr.prototype.renderInnerContent=function(){var e,t=this.layers.length;for(e=0;e=0;--n)e.finalTransform.multiply(e.transforms[n].transform.mProps.v);e._mdf=i},processSequences:function(e){var t,n=this.sequenceList.length;for(t=0;t=1){this.buffers=[];var e=this.globalData.canvasContext,t=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(t);var n=lr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(n),this.data.tt>=3&&!document._isProxy&&lr.loadLumaCanvas()}this.canvasContext=this.globalData.canvasContext,this.transformCanvas=this.globalData.transformCanvas,this.renderableEffectsManager=new dr(this),this.searchEffectTransforms()},createContent:function(){},setBlendMode:function(){var e=this.globalData;if(e.blendMode!==this.data.bm){e.blendMode=this.data.bm;var t=en(this.data.bm);e.canvasContext.globalCompositeOperation=t}},createRenderableComponents:function(){this.maskManager=new fr(this.data,this),this.transformEffects=this.renderableEffectsManager.getEffects(_n.TRANSFORM_EFFECT)},hideElement:function(){!this.hidden&&(!this.isInRange||this.isTransparent)&&(this.hidden=!0)},showElement:function(){this.isInRange&&!this.isTransparent&&(this.hidden=!1,this._isFirstFrame=!0,this.maskManager._isFirstFrame=!0)},clearCanvas:function(e){e.clearRect(this.transformCanvas.tx,this.transformCanvas.ty,this.transformCanvas.w*this.transformCanvas.sx,this.transformCanvas.h*this.transformCanvas.sy)},prepareLayer:function(){if(this.data.tt>=1){var e=this.buffers[0].getContext(`2d`);this.clearCanvas(e),e.drawImage(this.canvasContext.canvas,0,0),this.currentTransform=this.canvasContext.getTransform(),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform)}},exitLayer:function(){if(this.data.tt>=1){var e=this.buffers[1],t=e.getContext(`2d`);if(this.clearCanvas(t),t.drawImage(this.canvasContext.canvas,0,0),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform),this.comp.getElementById(`tp`in this.data?this.data.tp:this.data.ind-1).renderFrame(!0),this.canvasContext.setTransform(1,0,0,1,0,0),this.data.tt>=3&&!document._isProxy){var n=lr.getLumaCanvas(this.canvasContext.canvas);n.getContext(`2d`).drawImage(this.canvasContext.canvas,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.drawImage(n,0,0)}this.canvasContext.globalCompositeOperation=mr[this.data.tt],this.canvasContext.drawImage(e,0,0),this.canvasContext.globalCompositeOperation=`destination-over`,this.canvasContext.drawImage(this.buffers[0],0,0),this.canvasContext.setTransform(this.currentTransform),this.canvasContext.globalCompositeOperation=`source-over`}},renderFrame:function(e){if(!(this.hidden||this.data.hd)&&!(this.data.td===1&&!e)){this.renderTransform(),this.renderRenderable(),this.renderLocalTransform(),this.setBlendMode();var t=this.data.ty===0;this.prepareLayer(),this.globalData.renderer.save(t),this.globalData.renderer.ctxTransform(this.finalTransform.localMat.props),this.globalData.renderer.ctxOpacity(this.finalTransform.localOpacity),this.renderInnerContent(),this.globalData.renderer.restore(t),this.exitLayer(),this.maskManager.hasMasks&&this.globalData.renderer.restore(!0),this._isFirstFrame&&=!1}},destroy:function(){this.canvasContext=null,this.data=null,this.globalData=null,this.maskManager.destroy()},mHelper:new K},pr.prototype.hide=pr.prototype.hideElement,pr.prototype.show=pr.prototype.showElement;function hr(e,t,n,r){this.styledShapes=[],this.tr=[0,0,0,0,0,0];var i=4;t.ty===`rc`?i=5:t.ty===`el`?i=6:t.ty===`sr`&&(i=7),this.sh=G.getShapeProp(e,t,i,e);var a,o=n.length,s;for(a=0;a=0;--a){if(d=this.searchProcessedElement(e[a]),d?t[a]=n[d-1]:e[a]._shouldRender=r,e[a].ty===`fl`||e[a].ty===`st`||e[a].ty===`gf`||e[a].ty===`gs`)d?t[a].style.closed=!1:t[a]=this.createStyleElement(e[a],m),l.push(t[a].style);else if(e[a].ty===`gr`){if(!d)t[a]=this.createGroupElement(e[a]);else for(c=t[a].it.length,s=0;s=0;--i)t[i].ty===`tr`?(o=n[i].transform,this.renderShapeTransform(e,o)):t[i].ty===`sh`||t[i].ty===`el`||t[i].ty===`rc`||t[i].ty===`sr`?this.renderPath(t[i],n[i]):t[i].ty===`fl`?this.renderFill(t[i],n[i],o):t[i].ty===`st`?this.renderStroke(t[i],n[i],o):t[i].ty===`gf`||t[i].ty===`gs`?this.renderGradientFill(t[i],n[i],o):t[i].ty===`gr`?this.renderShape(o,t[i].it,n[i].it):t[i].ty;r&&this.drawLayer()},gr.prototype.renderStyledShape=function(e,t){if(this._isFirstFrame||t._mdf||e.transforms._mdf){var n=e.trNodes,r=t.paths,i,a,o,s=r._length;n.length=0;var c=e.transforms.finalTransform;for(o=0;o=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;ma&&c===`xMidYMid slice`||ii&&s===`meet`||ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))/2*this.renderConfig.dpr:l===`xMax`&&(ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))*this.renderConfig.dpr:this.transformCanvas.tx=0,u===`YMid`&&(a>i&&s===`meet`||ai&&s===`meet`||a=0;--e)this.elements[e]&&this.elements[e].destroy&&this.elements[e].destroy();this.elements.length=0,this.globalData.canvasContext=null,this.animationItem.container=null,this.destroyed=!0},Z.prototype.renderFrame=function(e,t){if(!(this.renderedFrame===e&&this.renderConfig.clearCanvas===!0&&!t||this.destroyed||e===-1)){this.renderedFrame=e,this.globalData.frameNum=e-this.animationItem._isFirstFrame,this.globalData.frameId+=1,this.globalData._mdf=!this.renderConfig.clearCanvas||t,this.globalData.projectInterface.currentFrame=e;var n,r=this.layers.length;for(this.completeLayers||this.checkLayers(e),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].prepareFrame(e-this.layers[n].st);if(this.globalData._mdf){for(this.renderConfig.clearCanvas===!0?this.canvasContext.clearRect(0,0,this.transformCanvas.w,this.transformCanvas.h):this.save(),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].renderFrame();this.renderConfig.clearCanvas!==!0&&this.restore()}}},Z.prototype.buildItem=function(e){var t=this.elements;if(!(t[e]||this.layers[e].ty===99)){var n=this.createItem(this.layers[e],this,this.globalData);t[e]=n,n.initExpressions()}},Z.prototype.checkPendingElements=function(){for(;this.pendingElements.length;)this.pendingElements.pop().checkParenting()},Z.prototype.hide=function(){this.animationItem.container.style.display=`none`},Z.prototype.show=function(){this.animationItem.container.style.display=`block`};function br(){this.opacity=-1,this.transform=p(`float32`,16),this.fillStyle=``,this.strokeStyle=``,this.lineWidth=``,this.lineCap=``,this.lineJoin=``,this.miterLimit=``,this.id=Math.random()}function xr(){this.stack=[],this.cArrPos=0,this.cTr=new K;var e,t=15;for(e=0;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].renderFrame()},Sr.prototype.destroy=function(){var e;for(e=this.layers.length-1;e>=0;--e)this.elements[e]&&this.elements[e].destroy();this.layers=null,this.elements=null},Sr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)};function Cr(e,t){this.animationItem=e,this.renderConfig={clearCanvas:t&&t.clearCanvas!==void 0?t.clearCanvas:!0,context:t&&t.context||null,progressiveLoad:t&&t.progressiveLoad||!1,preserveAspectRatio:t&&t.preserveAspectRatio||`xMidYMid meet`,imagePreserveAspectRatio:t&&t.imagePreserveAspectRatio||`xMidYMid slice`,contentVisibility:t&&t.contentVisibility||`visible`,className:t&&t.className||``,id:t&&t.id||``,runExpressions:!t||t.runExpressions===void 0||t.runExpressions},this.renderConfig.dpr=t&&t.dpr||1,this.animationItem.wrapper&&(this.renderConfig.dpr=t&&t.dpr||window.devicePixelRatio||1),this.renderedFrame=-1,this.globalData={frameNum:-1,_mdf:!1,renderConfig:this.renderConfig,currentGlobalAlpha:-1},this.contextData=new xr,this.elements=[],this.pendingElements=[],this.transformMat=new K,this.completeLayers=!1,this.rendererType=`canvas`,this.renderConfig.clearCanvas&&(this.ctxTransform=this.contextData.transform.bind(this.contextData),this.ctxOpacity=this.contextData.opacity.bind(this.contextData),this.ctxFillStyle=this.contextData.fillStyle.bind(this.contextData),this.ctxStrokeStyle=this.contextData.strokeStyle.bind(this.contextData),this.ctxLineWidth=this.contextData.lineWidth.bind(this.contextData),this.ctxLineCap=this.contextData.lineCap.bind(this.contextData),this.ctxLineJoin=this.contextData.lineJoin.bind(this.contextData),this.ctxMiterLimit=this.contextData.miterLimit.bind(this.contextData),this.ctxFill=this.contextData.fill.bind(this.contextData),this.ctxFillRect=this.contextData.fillRect.bind(this.contextData),this.ctxStroke=this.contextData.stroke.bind(this.contextData),this.save=this.contextData.save.bind(this.contextData))}return u([Z],Cr),Cr.prototype.createComp=function(e){return new Sr(e,this.globalData,this)},_e(`canvas`,Cr),dt.registerModifier(`tm`,pt),dt.registerModifier(`pb`,mt),dt.registerModifier(`rp`,gt),dt.registerModifier(`rd`,_t),dt.registerModifier(`zz`,Ft),dt.registerModifier(`op`,Jt),J}))}))(),1),_t=0,vt=e=>`${e}-${++_t}`,yt=e=>({key:vt(e),name:``,rarity:`1000`,sortOrder:`0`,file:null,animation:null,fileError:``}),bt=()=>({key:vt(`backdrop`),name:``,backdropID:`1`,rarity:`1000`,sortOrder:`0`,center:`#6f5bea`,edge:`#34278f`,pattern:`#a89df5`,text:`#ffffff`});function xt({data:e,compact:t=!1}){let n=(0,g.useRef)(null);return(0,g.useEffect)(()=>{if(!n.current)return;let t=gt.default.loadAnimation({container:n.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)});return()=>t.destroy()},[e]),(0,V.jsx)(`div`,{className:`collectible-animation ${t?`compact`:``}`,ref:n})}function St({giftID:e,attribute:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n=!1;return a(!1),x.giftCollectibleAnimation(e,t.kind,t.id).then(e=>{n||r(e)}).catch(()=>{n||a(!0)}),()=>{n=!0}},[e,t.id,t.kind]),i?(0,V.jsx)(`div`,{className:`collectible-animation compact failed`,children:`!`}):n?(0,V.jsx)(xt,{data:n,compact:!0}):(0,V.jsx)(`div`,{className:`collectible-animation compact loading`,children:(0,V.jsx)(A,{className:`spin`,size:15})})}async function Ct(e){let t=new Uint8Array(await e.arrayBuffer()),n=t;if(t.length>=2&&t[0]===31&&t[1]===139){if(!(`DecompressionStream`in window))throw Error(`This browser cannot preview TGS files`);let e=new Blob([t]).stream().pipeThrough(new DecompressionStream(`gzip`));n=new Uint8Array(await new Response(e).arrayBuffer())}let r=JSON.parse(new TextDecoder().decode(n));if(!r||typeof r!=`object`||Array.isArray(r))throw Error(`Invalid Lottie JSON`);return r}var wt=e=>Number.parseInt(e.replace(`#`,``),16);function Tt({gift:e,onClose:t,onPublished:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(!0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(`100`),[_,v]=(0,g.useState)(`1000`),[y,S]=(0,g.useState)(`gift-${e.GiftID}`),[C,w]=(0,g.useState)(``),[T,E]=(0,g.useState)([yt(`model`)]),[D,O]=(0,g.useState)([yt(`pattern`)]),[M,N]=(0,g.useState)([bt()]);(0,g.useEffect)(()=>{let t=!1;return x.giftCollectibles(e.GiftID).then(n=>{t||(a(n),n.found&&(h(String(n.upgrade_stars??100)),v(String(n.supply_total??1e3)),S(n.slug_prefix??`gift-${e.GiftID}`)))}).catch(e=>d(b(e))).finally(()=>{t||s(!1)}),()=>{t=!0}},[e.GiftID]);let P=(0,g.useMemo)(()=>({models:T.reduce((e,t)=>e+Number(t.rarity||0),0),patterns:D.reduce((e,t)=>e+Number(t.rarity||0),0),backdrops:M.reduce((e,t)=>e+Number(t.rarity||0),0)}),[T,D,M]),F=()=>p(null),I=(e,t,n)=>{(e===`models`?E:O)(e=>e.map(e=>e.key===t?{...e,...n}:e)),F()};async function L(e,t,n){if(I(e,t.key,{file:n,animation:null,fileError:``}),n)try{let r=await Ct(n);I(e,t.key,{animation:r,fileError:``})}catch(n){I(e,t.key,{animation:null,fileError:b(n)})}}function ee(e,t=``){if(!C.trim())throw Error(r(`action.reasonRequired`));for(let e of[...T,...D])if(!e.file)throw Error(r(`collectibles.fileRequired`));let n=new FormData,i=e=>e.map(e=>({name:e.name.trim(),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),file_key:e.key}));n.set(`metadata`,JSON.stringify({command_id:t,reason:C.trim(),confirm:e,upgrade_stars:Number(m),supply_total:Number(_),slug_prefix:y.trim().toLowerCase(),models:i(T),patterns:i(D),backdrops:M.map(e=>({name:e.name.trim(),backdrop_id:Number(e.backdropID),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),center_color:wt(e.center),edge_color:wt(e.edge),pattern_color:wt(e.pattern),text_color:wt(e.text)}))}));for(let e of[...T,...D])n.set(e.key,e.file,e.file.name);return n}async function te(){l(!0),d(``),p(null);try{p(await x.publishGiftCollectibles(e.GiftID,ee(!1)))}catch(e){d(b(e))}finally{l(!1)}}async function re(){if(f){l(!0),d(``);try{await x.publishGiftCollectibles(e.GiftID,ee(!0,f.command_id)),n(),t()}catch(e){d(b(e))}finally{l(!1)}}}let ie=(e,t,n)=>(0,V.jsxs)(`section`,{className:`collectible-section`,children:[(0,V.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.${e}`)}),(0,V.jsx)(`span`,{children:r(`collectibles.rarityHint`)})]}),(0,V.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,V.jsxs)(G,{tone:P[e]===1e3?`good`:`neutral`,children:[P[e],` / 1000`]}),(0,V.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{n([...t,yt(e===`models`?`model`:`pattern`)]),F()},children:[(0,V.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,V.jsx)(`div`,{className:`collectible-rows`,children:t.map((i,a)=>(0,V.jsxs)(`div`,{className:`collectible-row animated`,children:[(0,V.jsx)(`div`,{className:`collectible-row-index`,children:a+1}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`common.name`)}),(0,V.jsx)(`input`,{value:i.name,maxLength:128,onChange:t=>I(e,i.key,{name:t.target.value})})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:i.rarity,onChange:t=>I(e,i.key,{rarity:t.target.value})})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:i.sortOrder,onChange:t=>I(e,i.key,{sortOrder:t.target.value})})]}),(0,V.jsxs)(`label`,{className:`collectible-file`,children:[(0,V.jsx)(`span`,{children:r(`gifts.animation`)}),(0,V.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:t=>void L(e,i,t.target.files?.[0]??null)}),(0,V.jsxs)(`em`,{children:[(0,V.jsx)(R,{size:13}),i.file?.name??r(`gifts.chooseFile`)]})]}),(0,V.jsx)(`div`,{className:`collectible-inline-preview`,children:i.animation?(0,V.jsx)(xt,{data:i.animation,compact:!0}):(0,V.jsx)(j,{size:16})}),(0,V.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:t.length===1,onClick:()=>{n(t.filter(e=>e.key!==i.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,V.jsx)(ge,{size:14})}),i.fileError&&(0,V.jsx)(`span`,{className:`collectible-file-error`,children:i.fileError})]},i.key))})]});return(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal collectible-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(`collectibles.title`,{id:e.GiftID}),children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:r(`collectibles.eyebrow`)}),(0,V.jsx)(`h2`,{children:r(`collectibles.title`,{id:e.GiftID})}),(0,V.jsx)(`p`,{children:e.Title||`Gift #${e.GiftID}`})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:c,"aria-label":r(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body collectible-modal-body`,children:[o?(0,V.jsxs)(`div`,{className:`collectible-loading`,children:[(0,V.jsx)(A,{className:`spin`}),r(`common.loading`)]}):i?.found?(0,V.jsxs)(`section`,{className:`collectible-active`,children:[(0,V.jsxs)(`div`,{className:`collectible-active-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(ne,{size:18}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.activeRevision`,{revision:i.revision??0})}),(0,V.jsxs)(`span`,{children:[i.slug_prefix,` · ⭐ `,i.upgrade_stars,` · `,i.issued,` / `,i.supply_total]})]})]}),(0,V.jsx)(G,{tone:`good`,children:r(`collectibles.published`)})]}),(0,V.jsxs)(`div`,{className:`collectible-active-grid`,children:[[...i.models??[],...i.patterns??[]].map(t=>(0,V.jsxs)(`article`,{children:[(0,V.jsx)(St,{giftID:e.GiftID,attribute:t}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:t.name}),(0,V.jsxs)(`span`,{children:[r(`collectibles.${t.kind}`),` · `,t.rarity_permille,`‰`]})]})]},`${t.kind}-${t.id}`)),(i.backdrops??[]).map(e=>(0,V.jsxs)(`article`,{children:[(0,V.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, #${(e.center_color??0).toString(16).padStart(6,`0`)}, #${(e.edge_color??0).toString(16).padStart(6,`0`)})`,color:`#${(e.text_color??16777215).toString(16).padStart(6,`0`)}`},children:`Aa`}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:e.name}),(0,V.jsxs)(`span`,{children:[r(`collectibles.backdrop`),` · `,e.rarity_permille,`‰`]})]})]},`backdrop-${e.id}`))]})]}):(0,V.jsxs)(`div`,{className:`collectible-empty`,children:[(0,V.jsx)(ne,{size:22}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.noPool`)}),(0,V.jsx)(`span`,{children:r(`collectibles.noPoolHint`)})]})]}),(0,V.jsxs)(`section`,{className:`collectible-definition`,children:[(0,V.jsxs)(`div`,{className:`collectible-definition-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.publishNew`)}),(0,V.jsx)(`span`,{children:r(`collectibles.immutableHint`)})]}),(0,V.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,V.jsx)(`span`,{children:`TGS`}),(0,V.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,V.jsxs)(`div`,{className:`gift-fields-grid collectible-main-fields`,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.upgradeStars`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:m,onChange:e=>{h(e.target.value),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.supply`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>{v(e.target.value),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.slug`)}),(0,V.jsx)(`input`,{value:y,maxLength:48,onChange:e=>{S(e.target.value.toLowerCase()),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.reason`)}),(0,V.jsx)(`input`,{value:C,maxLength:1e3,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>w(e.target.value)})]})]}),ie(`models`,T,E),ie(`patterns`,D,O),(0,V.jsxs)(`section`,{className:`collectible-section`,children:[(0,V.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.backdrops`)}),(0,V.jsx)(`span`,{children:r(`collectibles.colorHint`)})]}),(0,V.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,V.jsxs)(G,{tone:P.backdrops===1e3?`good`:`neutral`,children:[P.backdrops,` / 1000`]}),(0,V.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{N([...M,bt()]),F()},children:[(0,V.jsx)(z,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,V.jsx)(`div`,{className:`collectible-rows`,children:M.map((e,t)=>(0,V.jsxs)(`div`,{className:`collectible-row backdrop`,children:[(0,V.jsx)(`div`,{className:`collectible-row-index`,children:t+1}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`common.name`)}),(0,V.jsx)(`input`,{value:e.name,maxLength:128,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,name:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.backdropID`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:e.backdropID,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,backdropID:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:e.rarity,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,rarity:t.target.value}:n)),F()}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:e.sortOrder,onChange:t=>{N(M.map(n=>n.key===e.key?{...n,sortOrder:t.target.value}:n)),F()}})]}),[`center`,`edge`,`pattern`,`text`].map(t=>(0,V.jsxs)(`label`,{className:`collectible-color`,children:[(0,V.jsx)(`span`,{children:r(`collectibles.color.${t}`)}),(0,V.jsx)(`input`,{type:`color`,value:e[t],onChange:n=>{N(M.map(r=>r.key===e.key?{...r,[t]:n.target.value}:r)),F()}})]},t)),(0,V.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, ${e.center}, ${e.edge})`,color:e.text},children:`Aa`}),(0,V.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:M.length===1,onClick:()=>{N(M.filter(t=>t.key!==e.key)),F()},"aria-label":r(`collectibles.remove`),children:(0,V.jsx)(ge,{size:14})})]},e.key))})]})]}),u&&(0,V.jsx)(Ke,{children:u}),f&&(0,V.jsxs)(`div`,{className:`gift-validation`,children:[(0,V.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,V.jsx)(k,{size:17}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:r(`collectibles.validationReady`)}),(0,V.jsx)(`span`,{children:r(`collectibles.validationHint`)})]})]}),(0,V.jsx)(`pre`,{children:JSON.stringify(f.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:c,children:r(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:te,disabled:c,children:[c?(0,V.jsx)(A,{className:`spin`,size:15}):(0,V.jsx)(pe,{size:15}),r(`gifts.validate`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:re,disabled:c||!f,children:[(0,V.jsx)(_e,{size:15}),r(`collectibles.publish`)]})]})]})}),document.body)}function Et(e){return e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function Dt({giftID:e,revision:t,compact:n=!1}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(!0),[s,c]=(0,g.useState)(``);(0,g.useEffect)(()=>{let t=!1;return x.giftAnimation(e).then(e=>{t||!r.current||(i.current?.destroy(),i.current=gt.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(e=>c(b(e))),()=>{t=!0,i.current?.destroy(),i.current=null}},[e,t]);function l(){i.current&&(a?i.current.pause():i.current.play(),o(!a))}return(0,V.jsxs)(`div`,{className:`gift-animation-shell ${n?`compact`:``}`,children:[(0,V.jsx)(`div`,{className:`gift-animation`,ref:r,children:s&&(0,V.jsx)(`span`,{children:s})}),(0,V.jsx)(`button`,{className:`gift-play`,type:`button`,onClick:l,"aria-label":a?`Pause`:`Play`,children:a?(0,V.jsx)(le,{size:14}):(0,V.jsx)(ue,{size:14})})]})}function Ot(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(0),[p,m]=(0,g.useState)(``),[h,_]=(0,g.useState)(`50`),[v,y]=(0,g.useState)(`50`),[S,C]=(0,g.useState)(`0`),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(``),[O,j]=(0,g.useState)(null),[M,N]=(0,g.useState)(!1),[P,F]=(0,g.useState)(``),[I,L]=(0,g.useState)(``);async function ee(){F(``);try{n((await x.gifts()).Gifts??[])}catch(e){F(b(e))}}(0,g.useEffect)(()=>{ee()},[]);let te=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)||t.SourceFormat.toLowerCase().includes(e)):t},[t,r]);function re(t,n=``){if(!l)throw Error(e(`gifts.fileRequired`));if(!E.trim())throw Error(e(`action.reasonRequired`));let r=new FormData;return r.set(`metadata`,JSON.stringify({command_id:n,reason:E.trim(),confirm:t,gift_id:d,title:p.trim(),stars:Number(h),convert_stars:Number(v),enabled:w,sort_order:Number(S)})),r.set(`file`,l,l.name),r}async function ie(){N(!0),L(``),j(null);try{j(await x.importGift(re(!1)))}catch(e){L(b(e))}finally{N(!1)}}async function ae(){if(O){N(!0),L(``);try{await x.importGift(re(!0,O.command_id)),j(null),u(null),f(0),m(``),await ee(),o(!1)}catch(e){L(b(e))}finally{N(!1)}}}function oe(){f(0),m(``),_(`50`),y(`50`),C(`0`),T(!0),D(``),u(null),j(null),L(``),o(!0)}function se(e){f(e.GiftID),m(e.Title),_(String(e.Stars)),y(String(e.ConvertStars)),C(String(e.SortOrder)),T(e.Enabled),D(``),u(null),j(null),L(``),o(!0)}return(0,V.jsxs)(He,{title:e(`gifts.pageTitle`),eyebrow:e(`gifts.eyebrow`),actions:(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>ee(),disabled:M,children:[(0,V.jsx)(de,{size:15}),` `,e(`common.refresh`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:oe,children:[(0,V.jsx)(z,{size:15}),` `,e(`gifts.add`)]})]}),children:[P&&(0,V.jsx)(Ke,{children:P}),(0,V.jsxs)(`div`,{className:`metric-row gift-metrics`,children:[(0,V.jsx)(q,{label:e(`gifts.total`),value:String(t.length)}),(0,V.jsx)(q,{label:e(`gifts.enabled`),value:String(t.filter(e=>e.Enabled).length),tone:`good`}),(0,V.jsx)(q,{label:e(`gifts.received`),value:String(t.reduce((e,t)=>e+t.ReceivedCount,0))}),(0,V.jsx)(q,{label:e(`gifts.formats`),value:`TGS / Lottie`})]}),(0,V.jsx)(Ue,{children:(0,V.jsxs)(`div`,{className:`toolbar`,children:[(0,V.jsxs)(`label`,{className:`searchbox`,children:[(0,V.jsx)(B,{size:15}),(0,V.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`gifts.searchPlaceholder`)})]}),(0,V.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:te.length,total:t.length})})]})}),(0,V.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,V.jsxs)(`table`,{className:`data-table gift-table`,children:[(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{children:[(0,V.jsx)(`th`,{children:e(`gifts.animation`)}),(0,V.jsx)(`th`,{children:e(`gifts.idRevision`)}),(0,V.jsx)(`th`,{children:e(`gifts.title`)}),(0,V.jsx)(`th`,{children:e(`gifts.price`)}),(0,V.jsx)(`th`,{children:e(`gifts.source`)}),(0,V.jsx)(`th`,{children:e(`gifts.received`)}),(0,V.jsx)(`th`,{children:e(`common.status`)}),(0,V.jsx)(`th`,{children:e(`common.updatedAt`)}),(0,V.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,V.jsxs)(`tbody`,{children:[te.map(t=>(0,V.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,V.jsx)(`td`,{children:(0,V.jsx)(Dt,{giftID:t.GiftID,revision:t.Revision,compact:!0})}),(0,V.jsxs)(`td`,{className:`mono`,children:[t.GiftID,` / `,t.Revision]}),(0,V.jsxs)(`td`,{children:[(0,V.jsx)(`strong`,{className:`gift-table-title`,children:t.Title||`Gift #${t.GiftID}`}),(0,V.jsxs)(`span`,{className:`gift-sort-order`,children:[e(`gifts.sortOrder`),`: `,t.SortOrder]})]}),(0,V.jsxs)(`td`,{children:[(0,V.jsxs)(`strong`,{className:`gift-table-price`,children:[`⭐ `,t.Stars]}),(0,V.jsxs)(`span`,{className:`gift-convert-price`,children:[`→ `,t.ConvertStars]})]}),(0,V.jsxs)(`td`,{children:[(0,V.jsx)(G,{children:t.SourceFormat}),(0,V.jsx)(`span`,{className:`gift-source-size`,children:Et(t.AnimationSize)})]}),(0,V.jsx)(`td`,{children:t.ReceivedCount}),(0,V.jsx)(`td`,{children:(0,V.jsx)(G,{tone:t.Enabled?`good`:`neutral`,children:t.Enabled?e(`common.enabled`):e(`common.disabled`)})}),(0,V.jsx)(`td`,{children:ze(t.UpdatedAt)}),(0,V.jsx)(`td`,{children:(0,V.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,V.jsxs)(`button`,{className:`btn compact-btn collectible-button`,type:`button`,onClick:()=>c(t),children:[(0,V.jsx)(ne,{size:13}),e(`collectibles.manage`)]}),(0,V.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>se(t),children:e(`gifts.replace`)}),(0,V.jsx)($e,{compact:!0,tone:`neutral`,label:t.Enabled?e(`gifts.disable`):e(`gifts.enable`),path:`/api/actions/set-gift-enabled`,payload:()=>({gift_id:t.GiftID,enabled:!t.Enabled}),onDone:()=>void ee()})]})})]},t.GiftID)),te.length===0&&(0,V.jsx)(Je,{colSpan:9})]})]})}),a&&(0,Qe.createPortal)((0,V.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,V.jsxs)(`section`,{className:`modal command-modal gift-import-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":d?e(`gifts.newRevision`,{id:d}):e(`gifts.importTitle`),children:[(0,V.jsxs)(`div`,{className:`modal-head`,children:[(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`div`,{className:`eyebrow`,children:e(`gifts.importEyebrow`)}),(0,V.jsx)(`h2`,{children:d?e(`gifts.newRevision`,{id:d}):e(`gifts.importTitle`)})]}),(0,V.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>o(!1),disabled:M,"aria-label":e(`action.close`),children:(0,V.jsx)(ye,{size:15})})]}),(0,V.jsxs)(`div`,{className:`command-body gift-import-modal-body`,children:[(0,V.jsxs)(`div`,{className:`command-steps`,children:[(0,V.jsxs)(`div`,{className:`command-step ${l?`done`:`active`}`,children:[(0,V.jsx)(`span`,{children:`1`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepDetails`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${O?`done`:l?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`2`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepValidate`)})]}),(0,V.jsxs)(`div`,{className:`command-step ${O?`active`:``}`,children:[(0,V.jsx)(`span`,{children:`3`}),(0,V.jsx)(`strong`,{children:e(`gifts.stepImport`)})]})]}),(0,V.jsxs)(`div`,{className:`gift-import-note`,children:[(0,V.jsx)(`span`,{children:e(`gifts.importHint`)}),(0,V.jsxs)(`div`,{className:`gift-format-chips`,"aria-label":e(`gifts.formats`),children:[(0,V.jsx)(`span`,{children:`TGS`}),(0,V.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,V.jsxs)(`label`,{className:`gift-file-picker ${l?`has-file`:``}`,children:[(0,V.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:e=>{u(e.target.files?.[0]??null),j(null)}}),(0,V.jsx)(`span`,{className:`gift-file-icon`,children:(0,V.jsx)(R,{size:22})}),(0,V.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,V.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.animation`)}),(0,V.jsx)(`strong`,{children:l?l.name:e(`gifts.filePrompt`)}),(0,V.jsx)(`small`,{children:l?Et(l.size):e(`gifts.fileHint`)})]}),(0,V.jsx)(`span`,{className:`gift-file-action`,children:e(l?`gifts.changeFile`:`gifts.chooseFile`)})]}),(0,V.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.title`)}),(0,V.jsx)(`input`,{value:p,maxLength:128,placeholder:e(`gifts.titlePlaceholder`),onChange:e=>{m(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.stars`)}),(0,V.jsx)(`input`,{type:`number`,min:`1`,value:h,onChange:e=>{_(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.convertStars`)}),(0,V.jsx)(`input`,{type:`number`,min:`0`,value:v,onChange:e=>{y(e.target.value),j(null)}})]}),(0,V.jsxs)(`label`,{children:[(0,V.jsx)(`span`,{children:e(`gifts.sortOrder`)}),(0,V.jsx)(`input`,{type:`number`,value:S,onChange:e=>{C(e.target.value),j(null)}})]})]}),(0,V.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,V.jsx)(`span`,{children:e(`gifts.reason`)}),(0,V.jsx)(`input`,{value:E,placeholder:e(`gifts.reasonPlaceholder`),onChange:e=>D(e.target.value)})]}),(0,V.jsxs)(`label`,{className:`gift-switch`,children:[(0,V.jsx)(`input`,{type:`checkbox`,checked:w,onChange:e=>{T(e.target.checked),j(null)}}),(0,V.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,V.jsx)(`span`,{})}),(0,V.jsx)(`span`,{children:e(`gifts.enableAfterImport`)})]}),I&&(0,V.jsx)(Ke,{children:I}),O&&(0,V.jsxs)(`div`,{className:`gift-validation`,children:[(0,V.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,V.jsx)(k,{size:17}),(0,V.jsxs)(`div`,{children:[(0,V.jsx)(`strong`,{children:e(`gifts.validationReady`)}),(0,V.jsx)(`span`,{children:e(`gifts.validationHint`)})]})]}),(0,V.jsx)(`pre`,{children:JSON.stringify(O.details,null,2)})]})]}),(0,V.jsxs)(`div`,{className:`modal-actions`,children:[(0,V.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>o(!1),disabled:M,children:e(`common.close`)}),(0,V.jsxs)(`button`,{className:`btn`,type:`button`,onClick:ie,disabled:M,children:[M?(0,V.jsx)(A,{className:`spin`,size:15}):(0,V.jsx)(pe,{size:15}),e(`gifts.validate`)]}),(0,V.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:ae,disabled:M||!O,children:[(0,V.jsx)(_e,{size:15}),e(`gifts.confirmImport`)]})]})]})}),document.body),s&&(0,V.jsx)(Tt,{gift:s,onClose:()=>c(null),onPublished:()=>void ee()})]})}function kt({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1];return n?(0,V.jsx)(tt,{id:Number(n),navigate:t}):r?(0,V.jsx)(ot,{id:Number(r),navigate:t}):e.path===`/accounts`?(0,V.jsx)(at,{navigate:t}):e.path===`/channels`?(0,V.jsx)(st,{navigate:t}):e.path===`/gifts`?(0,V.jsx)(Ot,{}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,V.jsx)(mt,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,V.jsx)(ut,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,V.jsx)(pt,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,V.jsx)(ht,{navigate:t}):(0,V.jsx)(ct,{navigate:t})}function At(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Oe());(0,g.useEffect)(()=>{let e=()=>r(Oe());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{x.session().then(e=>t(e.actor)).catch(e=>{if(e instanceof v&&e.status===401){t(null);return}t(null)})},[]);let i=e=>{window.history.pushState(null,``,e),r(Oe())};return e===void 0?(0,V.jsx)(Me,{}):e===null?(0,V.jsx)(Ze,{onLogin:t}):(0,V.jsx)(Ne,{actor:e,route:n,navigate:i,onLogout:()=>t(null),children:(0,V.jsx)(kt,{route:n,navigate:i})})}_.createRoot(document.getElementById(`root`)).render((0,V.jsx)(g.StrictMode,{children:(0,V.jsx)(Ce,{children:(0,V.jsx)(At,{})})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/index.html b/cmd/telesrv-admin/web/dist/index.html index 3b2622fc..788b9ebd 100644 --- a/cmd/telesrv-admin/web/dist/index.html +++ b/cmd/telesrv-admin/web/dist/index.html @@ -4,8 +4,8 @@ telesrv admin - - + +
diff --git a/cmd/telesrv-admin/web/src/api.ts b/cmd/telesrv-admin/web/src/api.ts index dd4a3245..75bc7d79 100644 --- a/cmd/telesrv-admin/web/src/api.ts +++ b/cmd/telesrv-admin/web/src/api.ts @@ -8,6 +8,7 @@ import type { GroupMessageListResponse, MessageDetail, MessageListResponse, + OfficialStarGiftListResponse, StarGiftCollectiblePreview, StarGiftListResponse } from "./types"; @@ -66,11 +67,14 @@ export const api = { return request(`/api/messages/groups/detail?${params.toString()}`); }, gifts: () => request("/api/gifts"), - giftAnimation: (id: number) => request>(`/api/gifts/${id}/animation`), - giftCollectibles: (id: number) => request(`/api/gifts/${id}/collectibles`), - giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`), + officialGifts: () => request("/api/official-gifts"), + officialGiftAnimation: (id: string) => request>(`/api/official-gifts/${encodeURIComponent(id)}/animation`), + giftAnimation: (id: string) => request>(`/api/gifts/${encodeURIComponent(id)}/animation`), + giftCollectibles: (id: string) => request(`/api/gifts/${encodeURIComponent(id)}/collectibles`), + giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`), importGift: (form: FormData) => request("/api/actions/import-gift", { method: "POST", body: form }), - publishGiftCollectibles: (giftID: number, form: FormData) => request(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }), + importOfficialGift: (payload: Record) => request("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }), + publishGiftCollectibles: (giftID: string, form: FormData) => request(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }), action: (path: string, payload: Record) => request(path, { method: "POST", body: JSON.stringify(payload) diff --git a/cmd/telesrv-admin/web/src/i18n.tsx b/cmd/telesrv-admin/web/src/i18n.tsx index bfc3ea5e..d1723df8 100644 --- a/cmd/telesrv-admin/web/src/i18n.tsx +++ b/cmd/telesrv-admin/web/src/i18n.tsx @@ -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; export type TFunction = (key: string, params?: TranslationParams) => string; @@ -83,6 +83,7 @@ const translations: Record> = { "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> = { "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> = { "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> = { "layout.logout": "退出", "language.en": "EN", "language.zh": "中文", + "language.ru": "RU", "login.heading": "运维后台", "login.body": "输入凭据后进入控制台。", "login.secret": "管理员密码或 token", @@ -608,6 +630,26 @@ const translations: Record> = { "gifts.importEyebrow": "礼物目录操作", "gifts.newRevision": "为礼物 #{id} 创建新版本", "gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。", + "gifts.officialSource": "官方资源库", + "gifts.fileSource": "上传文件", + "gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。", + "gifts.officialSearch": "搜索官方礼物 ID 或标题", + "gifts.officialSelect": "请选择官方礼物", + "gifts.officialRequired": "请先选择一个官方礼物", + "gifts.officialResults": "显示 {shown} / {total} 项", + "gifts.officialCategoryLabel": "官方礼物能力分类", + "gifts.officialCategory.all": "全部", + "gifts.officialCategory.upgrade": "可升级", + "gifts.officialCategory.craft": "可 Craft", + "gifts.officialCategory.basic": "不可升级", + "gifts.officialUnnamed": "未命名官方礼物 #{id}", + "gifts.officialAttributes": "{count} 个属性", + "gifts.canUpgrade": "可升级", + "gifts.cannotUpgrade": "不可升级", + "gifts.canCraft": "可 Craft", + "gifts.cannotCraft": "不可 Craft", + "gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。", + "gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)", "gifts.animation": "动画文件", "gifts.filePrompt": "拖放或选择 TGS / Lottie 文件", "gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验", @@ -655,8 +697,8 @@ const translations: Record> = { "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> = { "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 (
- {(["en", "zh"] as const).map((item) => ( + {(["en", "zh", "ru"] as const).map((item) => (
+
0 ? "good" : "neutral"}>{rarityTotals[kind]}‰
{rows.map((row, index) =>
@@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St {loading ?
{t("common.loading")}
: active?.found ?
{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}
{t("collectibles.published")}
- {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) =>
{attribute.name}{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}‰
)} - {(active.backdrops ?? []).map((attribute) =>
Aa
{attribute.name}{t("collectibles.backdrop")} · {attribute.rarity_permille}‰
)} + {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) =>
{attribute.name}{attribute.crafted && crafted}{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}
)} + {(active.backdrops ?? []).map((attribute) =>
Aa
{attribute.name}{t("collectibles.backdrop")} · {rarityLabel(attribute)}
)}
:
{t("collectibles.noPool")}{t("collectibles.noPoolHint")}
} @@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St {renderAnimatedRows("models", models, setModels)} {renderAnimatedRows("patterns", patterns, setPatterns)}
-
{t("collectibles.backdrops")}{t("collectibles.colorHint")}
{rarityTotals.backdrops} / 1000
+
{t("collectibles.backdrops")}{t("collectibles.colorHint")}
0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰
{backdrops.map((row, index) =>
{index + 1}
- + {(["center", "edge", "pattern", "text"] as const).map((field) => )} diff --git a/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx b/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx index 79e2b53e..c6441b18 100644 --- a/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx +++ b/cmd/telesrv-admin/web/src/pages/GiftsPage.tsx @@ -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(null); const animation = useRef | 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(null); + useEffect(() => { + let cancelled = false; + let player: ReturnType | 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
; +} + export function GiftsPage() { const { t } = useI18n(); const [gifts, setGifts] = useState([]); @@ -66,7 +87,16 @@ export function GiftsPage() { const [importOpen, setImportOpen] = useState(false); const [collectibleGift, setCollectibleGift] = useState(null); const [file, setFile] = useState(null); - const [giftID, setGiftID] = useState(0); + const [importSource, setImportSource] = useState<"official" | "file">("official"); + const [officialGifts, setOfficialGifts] = useState([]); + const [officialQuery, setOfficialQuery] = useState(""); + const [officialCategory, setOfficialCategory] = useState("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() {
gift.Enabled).length)} tone="good" /> - sum + gift.ReceivedCount, 0))} /> + sum + BigInt(gift.ReceivedCount), 0n).toString()} />
@@ -193,24 +273,77 @@ export function GiftsPage() { {importOpen && createPortal(
-
+
-
{t("gifts.importEyebrow")}

{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}

+
{t("gifts.importEyebrow")}

{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}

-
1{t("gifts.stepDetails")}
-
2{t("gifts.stepValidate")}
+
1{t("gifts.stepDetails")}
+
2{t("gifts.stepValidate")}
3{t("gifts.stepImport")}
-
{t("gifts.importHint")}
TGSLottie JSON
- +
+ + +
+ {importSource === "official" ?
+
{t("gifts.officialHint")}
{officialGifts.length}SHA-256
+
+ + {t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })} +
+
+ {(["all", "upgrade", "craft", "basic"] as const).map((category) => ( + + ))} +
+
+ {visibleOfficial.map((gift) => { + const selected = gift.source_gift_id === sourceGiftID; + return ; + })} + {visibleOfficial.length === 0 &&
{t("gifts.officialEmpty")}
} +
+ {selectedOfficial &&
+ +
{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}{selectedOfficial.source_gift_id}{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}
+
} + {selectedOfficial?.can_upgrade && <> + + {includeCollectible &&
+ + + +
} + } +
: <> +
{t("gifts.importHint")}
TGSLottie JSON
+ + }
diff --git a/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css b/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css index 0c6b0cb9..9d1b5684 100644 --- a/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css +++ b/cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css @@ -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; } diff --git a/cmd/telesrv-admin/web/src/types.ts b/cmd/telesrv-admin/web/src/types.ts index f95ca7fd..be00b027 100644 --- a/cmd/telesrv-admin/web/src/types.ts +++ b/cmd/telesrv-admin/web/src/types.ts @@ -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; diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index fa23e834..32893789 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -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) } diff --git a/cmd/walletminiapp/main.go b/cmd/walletminiapp/main.go index 12005ca1..444bc1f2 100644 --- a/cmd/walletminiapp/main.go +++ b/cmd/walletminiapp/main.go @@ -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 :") flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label") diff --git a/deploy/migrations/0001_init.up.sql b/deploy/migrations/0001_init.up.sql index 7fedd390..372df81e 100644 --- a/deploy/migrations/0001_init.up.sql +++ b/deploy/migrations/0001_init.up.sql @@ -5554,7 +5554,7 @@ SET row_security = off; -- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: - -- -INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0); +INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telesrv', '', 'telesrv', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0); INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (93372553, 7421896403922962293, '', 'BotFather', '', 'BotFather', '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', true, false, '', 0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0); diff --git a/deploy/migrations/0093_official_star_gift_attributes.down.sql b/deploy/migrations/0093_official_star_gift_attributes.down.sql new file mode 100644 index 00000000..b14c344d --- /dev/null +++ b/deploy/migrations/0093_official_star_gift_attributes.down.sql @@ -0,0 +1,72 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.star_gift_collectible_models + WHERE rarity_kind <> 'permille' OR crafted + ) THEN + RAISE EXCEPTION 'cannot downgrade while categorical/crafted collectible models exist'; + END IF; +END; +$$; + +DROP INDEX IF EXISTS public.star_gift_catalog_revisions_official_source_idx; + +ALTER TABLE public.star_gift_collectible_backdrops + DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_patterns + DROP CONSTRAINT star_gift_collectible_pattern_official_document_check, + DROP CONSTRAINT star_gift_collectible_pattern_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN official_document_id, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_models + DROP CONSTRAINT star_gift_collectible_model_official_document_check, + DROP CONSTRAINT star_gift_collectible_model_rarity_check, + ALTER COLUMN rarity_permille SET NOT NULL, + DROP COLUMN official_document_id, + DROP COLUMN crafted, + DROP COLUMN rarity_kind, + ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000); + +ALTER TABLE public.star_gift_collectible_revisions + DROP CONSTRAINT star_gift_collectible_official_source_check, + DROP COLUMN source_manifest_sha256, + DROP COLUMN official_gift_id; + +ALTER TABLE public.star_gift_catalog_revisions + DROP CONSTRAINT star_gift_catalog_official_source_check, + DROP COLUMN official_source, + DROP COLUMN source_manifest_sha256, + DROP COLUMN official_gift_id; + +CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.status = 'published' THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + RETURN OLD; + END IF; + + IF OLD.status = 'published' THEN + IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR + NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR + NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR + NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR + NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + IF NEW.issued <> OLD.issued + 1 THEN + RAISE EXCEPTION 'published collectible issuance must advance exactly once'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/deploy/migrations/0093_official_star_gift_attributes.up.sql b/deploy/migrations/0093_official_star_gift_attributes.up.sql new file mode 100644 index 00000000..f9168fe3 --- /dev/null +++ b/deploy/migrations/0093_official_star_gift_attributes.up.sql @@ -0,0 +1,88 @@ +-- Preserve the complete Layer 228 official collectible attribute shape. Display rarity is +-- distinct from regular-upgrade selection eligibility, and official provenance is recorded +-- on both immutable revisions created by one import command. + +ALTER TABLE public.star_gift_catalog_revisions + ADD COLUMN official_gift_id bigint, + ADD COLUMN source_manifest_sha256 bytea, + ADD COLUMN official_source jsonb, + ADD CONSTRAINT star_gift_catalog_official_source_check CHECK ( + (official_gift_id IS NULL AND source_manifest_sha256 IS NULL AND official_source IS NULL) OR + (official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND official_source IS NOT NULL AND + octet_length(source_manifest_sha256) = 32 AND jsonb_typeof(official_source) = 'object') + ); + +ALTER TABLE public.star_gift_collectible_revisions + ADD COLUMN official_gift_id bigint, + ADD COLUMN source_manifest_sha256 bytea, + ADD CONSTRAINT star_gift_collectible_official_source_check CHECK ( + (official_gift_id IS NULL AND source_manifest_sha256 IS NULL) OR + (official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND octet_length(source_manifest_sha256) = 32) + ); + +ALTER TABLE public.star_gift_collectible_models + DROP CONSTRAINT star_gift_collectible_model_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD COLUMN crafted boolean DEFAULT false NOT NULL, + ADD COLUMN official_document_id bigint, + ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK ( + rarity_kind IN ('permille', 'uncommon', 'rare', 'epic', 'legendary') AND + ((rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 AND NOT crafted) OR + (rarity_kind <> 'permille' AND rarity_permille IS NULL AND crafted)) + ), + ADD CONSTRAINT star_gift_collectible_model_official_document_check CHECK ( + official_document_id IS NULL OR official_document_id > 0 + ); + +ALTER TABLE public.star_gift_collectible_patterns + DROP CONSTRAINT star_gift_collectible_pattern_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD COLUMN official_document_id bigint, + ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK ( + rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 + ), + ADD CONSTRAINT star_gift_collectible_pattern_official_document_check CHECK ( + official_document_id IS NULL OR official_document_id > 0 + ); + +ALTER TABLE public.star_gift_collectible_backdrops + DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check, + ALTER COLUMN rarity_permille DROP NOT NULL, + ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL, + ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK ( + rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 + ); + +CREATE INDEX star_gift_catalog_revisions_official_source_idx + ON public.star_gift_catalog_revisions(official_gift_id, id DESC) + WHERE official_gift_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.status = 'published' THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + RETURN OLD; + END IF; + + IF OLD.status = 'published' THEN + IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR + NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR + NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR + NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR + NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at OR + NEW.official_gift_id IS DISTINCT FROM OLD.official_gift_id OR + NEW.source_manifest_sha256 IS DISTINCT FROM OLD.source_manifest_sha256 THEN + RAISE EXCEPTION 'published collectible revision is immutable'; + END IF; + IF NEW.issued <> OLD.issued + 1 THEN + RAISE EXCEPTION 'published collectible issuance must advance exactly once'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/deploy/migrations/0094_star_gift_catalog_shape.down.sql b/deploy/migrations/0094_star_gift_catalog_shape.down.sql new file mode 100644 index 00000000..b44323a4 --- /dev/null +++ b/deploy/migrations/0094_star_gift_catalog_shape.down.sql @@ -0,0 +1,34 @@ +DROP TABLE IF EXISTS public.star_gift_user_purchases; + +ALTER TABLE public.star_gift_catalog_revisions + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_background_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_auction_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_released_by_check, + DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_supply_check, + DROP COLUMN IF EXISTS background_text_color, + DROP COLUMN IF EXISTS background_edge_color, + DROP COLUMN IF EXISTS background_center_color, + DROP COLUMN IF EXISTS upgrade_variants, + DROP COLUMN IF EXISTS auction_start_date, + DROP COLUMN IF EXISTS gifts_per_round, + DROP COLUMN IF EXISTS auction_slug, + DROP COLUMN IF EXISTS locked_until_date, + DROP COLUMN IF EXISTS per_user_total, + DROP COLUMN IF EXISTS released_by_peer_id, + DROP COLUMN IF EXISTS released_by_peer_type, + DROP COLUMN IF EXISTS availability_total, + DROP COLUMN IF EXISTS auction, + DROP COLUMN IF EXISTS peer_color_available, + DROP COLUMN IF EXISTS limited_per_user, + DROP COLUMN IF EXISTS require_premium, + DROP COLUMN IF EXISTS birthday, + DROP COLUMN IF EXISTS sold_out, + DROP COLUMN IF EXISTS limited; + +ALTER TABLE public.star_gift_catalog + DROP CONSTRAINT IF EXISTS star_gift_catalog_inventory_check, + DROP COLUMN IF EXISTS last_sale_date, + DROP COLUMN IF EXISTS first_sale_date, + DROP COLUMN IF EXISTS availability_resale, + DROP COLUMN IF EXISTS resell_min_stars, + DROP COLUMN IF EXISTS availability_remains; diff --git a/deploy/migrations/0094_star_gift_catalog_shape.up.sql b/deploy/migrations/0094_star_gift_catalog_shape.up.sql new file mode 100644 index 00000000..3dce2b3f --- /dev/null +++ b/deploy/migrations/0094_star_gift_catalog_shape.up.sql @@ -0,0 +1,68 @@ +-- Preserve the complete Layer 228 regular StarGift shape. Release facts are immutable +-- catalog-revision data; inventory and sale timestamps belong to the mutable catalog +-- aggregate. Per-user ownership limits are enforced by the transaction boundary rather +-- than reconstructed from peer_star_gifts after the fact. + +ALTER TABLE public.star_gift_catalog + ADD COLUMN availability_remains integer DEFAULT 0 NOT NULL, + ADD COLUMN availability_resale bigint DEFAULT 0 NOT NULL, + ADD COLUMN resell_min_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN first_sale_date integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL, + ADD CONSTRAINT star_gift_catalog_inventory_check CHECK ( + availability_remains >= 0 AND availability_resale >= 0 AND resell_min_stars >= 0 AND + first_sale_date >= 0 AND last_sale_date >= 0 AND + (last_sale_date = 0 OR first_sale_date > 0) AND + (first_sale_date = 0 OR last_sale_date = 0 OR last_sale_date >= first_sale_date) + ); + +ALTER TABLE public.star_gift_catalog_revisions + ADD COLUMN limited boolean DEFAULT false NOT NULL, + ADD COLUMN sold_out boolean DEFAULT false NOT NULL, + ADD COLUMN birthday boolean DEFAULT false NOT NULL, + ADD COLUMN require_premium boolean DEFAULT false NOT NULL, + ADD COLUMN limited_per_user boolean DEFAULT false NOT NULL, + ADD COLUMN peer_color_available boolean DEFAULT false NOT NULL, + ADD COLUMN auction boolean DEFAULT false NOT NULL, + ADD COLUMN availability_total integer DEFAULT 0 NOT NULL, + ADD COLUMN released_by_peer_type text, + ADD COLUMN released_by_peer_id bigint, + ADD COLUMN per_user_total integer DEFAULT 0 NOT NULL, + ADD COLUMN locked_until_date integer DEFAULT 0 NOT NULL, + ADD COLUMN auction_slug text DEFAULT '' NOT NULL, + ADD COLUMN gifts_per_round integer DEFAULT 0 NOT NULL, + ADD COLUMN auction_start_date integer DEFAULT 0 NOT NULL, + ADD COLUMN upgrade_variants integer DEFAULT 0 NOT NULL, + ADD COLUMN background_center_color integer, + ADD COLUMN background_edge_color integer, + ADD COLUMN background_text_color integer, + ADD CONSTRAINT star_gift_catalog_revision_supply_check CHECK ( + availability_total >= 0 AND + per_user_total >= 0 AND locked_until_date >= 0 AND upgrade_variants >= 0 AND + ((limited AND availability_total > 0) OR (NOT limited AND availability_total = 0)) AND + (NOT sold_out OR limited) AND + ((limited_per_user AND per_user_total > 0) OR (NOT limited_per_user AND per_user_total = 0)) + ), + ADD CONSTRAINT star_gift_catalog_revision_released_by_check CHECK ( + (released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR + (released_by_peer_type IN ('user', 'chat', 'channel') AND released_by_peer_id > 0) + ), + ADD CONSTRAINT star_gift_catalog_revision_auction_check CHECK ( + (auction AND limited AND auction_slug <> '' AND gifts_per_round > 0 AND auction_start_date > 0) OR + (NOT auction AND auction_slug = '' AND gifts_per_round = 0 AND auction_start_date = 0) + ), + ADD CONSTRAINT star_gift_catalog_revision_background_check CHECK ( + (background_center_color IS NULL AND background_edge_color IS NULL AND background_text_color IS NULL) OR + (background_center_color BETWEEN 0 AND 16777215 AND + background_edge_color BETWEEN 0 AND 16777215 AND + background_text_color BETWEEN 0 AND 16777215) + ); + +CREATE TABLE public.star_gift_user_purchases ( + user_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + purchased_count integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_user_purchases_pkey PRIMARY KEY (user_id, gift_id), + CONSTRAINT star_gift_user_purchases_count_check CHECK (user_id > 0 AND purchased_count >= 0) +); diff --git a/deploy/migrations/0095_star_gift_lifecycle.down.sql b/deploy/migrations/0095_star_gift_lifecycle.down.sql new file mode 100644 index 00000000..7efc8026 --- /dev/null +++ b/deploy/migrations/0095_star_gift_lifecycle.down.sql @@ -0,0 +1,76 @@ +DROP TRIGGER IF EXISTS peer_unique_star_gift_owner_guard ON public.peer_star_gifts; +DROP TRIGGER IF EXISTS unique_star_gift_owner_guard ON public.unique_star_gifts; +DROP FUNCTION IF EXISTS public.telesrv_check_unique_star_gift_owner(); +DROP TRIGGER IF EXISTS star_gift_listing_guard ON public.star_gift_listings; +DROP FUNCTION IF EXISTS public.telesrv_guard_star_gift_listing(); + +DROP TABLE IF EXISTS public.ton_transactions; +DROP TABLE IF EXISTS public.ton_balances; +DROP TABLE IF EXISTS public.star_gift_auction_acquired; +DROP TABLE IF EXISTS public.star_gift_auction_bid_payments; +DROP TABLE IF EXISTS public.star_gift_auction_bids; +DROP TABLE IF EXISTS public.star_gift_auctions; +DROP TABLE IF EXISTS public.star_gift_withdrawal_requests; +DROP TABLE IF EXISTS public.star_gift_notification_settings; +DROP TABLE IF EXISTS public.star_gift_craft_commands; +DROP TABLE IF EXISTS public.star_gift_transfer_commands; +DROP TABLE IF EXISTS public.star_gift_purchase_commands; +DROP TABLE IF EXISTS public.star_gift_drop_details_commands; +DROP TABLE IF EXISTS public.star_gift_prepaid_upgrade_commands; +DROP TABLE IF EXISTS public.star_gift_offers; +DROP TABLE IF EXISTS public.star_gift_sales; +DROP TABLE IF EXISTS public.star_gift_listings; + +ALTER TABLE public.unique_star_gifts + DROP CONSTRAINT IF EXISTS unique_star_gift_value_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_original_owner_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_host_peer_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_theme_peer_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_released_by_check, + DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check, + DROP COLUMN IF EXISTS last_sale_amount, + DROP COLUMN IF EXISTS last_sale_currency, + DROP COLUMN IF EXISTS last_sale_date, + DROP COLUMN IF EXISTS craft_chance_permille, + DROP COLUMN IF EXISTS offer_min_stars, + DROP COLUMN IF EXISTS host_peer_id, + DROP COLUMN IF EXISTS host_peer_type, + DROP COLUMN IF EXISTS theme_peer_id, + DROP COLUMN IF EXISTS theme_peer_type, + DROP COLUMN IF EXISTS value_usd_amount, + DROP COLUMN IF EXISTS value_currency, + DROP COLUMN IF EXISTS value_amount, + DROP COLUMN IF EXISTS released_by_peer_id, + DROP COLUMN IF EXISTS released_by_peer_type, + DROP COLUMN IF EXISTS gift_address, + DROP COLUMN IF EXISTS owner_address, + DROP COLUMN IF EXISTS owner_name, + DROP COLUMN IF EXISTS crafted, + DROP COLUMN IF EXISTS original_owner_peer_id, + DROP COLUMN IF EXISTS original_owner_peer_type, + DROP COLUMN IF EXISTS burned, + DROP COLUMN IF EXISTS theme_available, + DROP COLUMN IF EXISTS resale_ton_only, + DROP COLUMN IF EXISTS require_premium; + +UPDATE public.unique_star_gifts u +SET owner_peer_type=p.owner_peer_type, owner_peer_id=p.owner_peer_id +FROM public.peer_star_gifts p +WHERE p.unique_gift_id=u.id AND (u.owner_peer_type IS NULL OR u.owner_peer_id IS NULL); +ALTER TABLE public.unique_star_gifts + ALTER COLUMN owner_peer_type SET NOT NULL, + ALTER COLUMN owner_peer_id SET NOT NULL, + ADD CONSTRAINT unique_star_gift_owner_check CHECK (owner_peer_type IN ('user','channel') AND owner_peer_id>0); + +ALTER TABLE public.peer_star_gifts + DROP COLUMN IF EXISTS prepaid_upgrade_hash, + DROP COLUMN IF EXISTS gift_num, + DROP CONSTRAINT IF EXISTS peer_star_gifts_lifecycle_check, + DROP COLUMN IF EXISTS can_craft_at, + DROP COLUMN IF EXISTS drop_original_details_stars, + DROP COLUMN IF EXISTS can_resell_at, + DROP COLUMN IF EXISTS can_transfer_at, + DROP COLUMN IF EXISTS can_export_at, + DROP COLUMN IF EXISTS transfer_stars, + DROP COLUMN IF EXISTS lifecycle_status, + ADD CONSTRAINT peer_star_gifts_terminal_state_check CHECK (NOT converted OR unique_gift_id IS NULL); diff --git a/deploy/migrations/0095_star_gift_lifecycle.up.sql b/deploy/migrations/0095_star_gift_lifecycle.up.sql new file mode 100644 index 00000000..162775ba --- /dev/null +++ b/deploy/migrations/0095_star_gift_lifecycle.up.sql @@ -0,0 +1,425 @@ +-- Complete collectible Star Gift lifecycle: ownership state, transfer/resale, purchase +-- offers, crafting, auctions, notification preferences and the explicit TON boundary. + +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_terminal_state_check, + ADD COLUMN lifecycle_status text DEFAULT 'active' NOT NULL, + ADD COLUMN transfer_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN prepaid_upgrade_hash text DEFAULT '' NOT NULL, + ADD COLUMN gift_num integer DEFAULT 0 NOT NULL, + ADD COLUMN can_export_at integer DEFAULT 0 NOT NULL, + ADD COLUMN can_transfer_at integer DEFAULT 0 NOT NULL, + ADD COLUMN can_resell_at integer DEFAULT 0 NOT NULL, + ADD COLUMN drop_original_details_stars bigint DEFAULT 0 NOT NULL, + ADD COLUMN can_craft_at integer DEFAULT 0 NOT NULL; + +UPDATE public.peer_star_gifts SET lifecycle_status='converted' WHERE converted; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT peer_star_gifts_lifecycle_check CHECK ( + lifecycle_status IN ('active', 'converted', 'burned', 'exported') AND + transfer_stars >= 0 AND gift_num >= 0 AND can_export_at >= 0 AND can_transfer_at >= 0 AND + can_resell_at >= 0 AND drop_original_details_stars >= 0 AND can_craft_at >= 0 AND + ((lifecycle_status='converted' AND converted AND unique_gift_id IS NULL) OR + (lifecycle_status='active' AND NOT converted) OR + (lifecycle_status IN ('burned','exported') AND NOT converted AND unique_gift_id IS NOT NULL)) + ); + +CREATE UNIQUE INDEX peer_star_gifts_prepaid_upgrade_hash_uniq + ON public.peer_star_gifts(prepaid_upgrade_hash) WHERE prepaid_upgrade_hash<>''; + +ALTER TABLE public.unique_star_gifts + ALTER COLUMN owner_peer_type DROP NOT NULL, + ALTER COLUMN owner_peer_id DROP NOT NULL, + DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check, + ADD COLUMN require_premium boolean DEFAULT false NOT NULL, + ADD COLUMN resale_ton_only boolean DEFAULT false NOT NULL, + ADD COLUMN theme_available boolean DEFAULT false NOT NULL, + ADD COLUMN burned boolean DEFAULT false NOT NULL, + ADD COLUMN crafted boolean DEFAULT false NOT NULL, + ADD COLUMN original_owner_peer_type text, + ADD COLUMN original_owner_peer_id bigint, + ADD COLUMN owner_name text DEFAULT '' NOT NULL, + ADD COLUMN owner_address text DEFAULT '' NOT NULL, + ADD COLUMN gift_address text DEFAULT '' NOT NULL, + ADD COLUMN released_by_peer_type text, + ADD COLUMN released_by_peer_id bigint, + ADD COLUMN value_amount bigint DEFAULT 0 NOT NULL, + ADD COLUMN value_currency text DEFAULT '' NOT NULL, + ADD COLUMN value_usd_amount bigint DEFAULT 0 NOT NULL, + ADD COLUMN theme_peer_type text, + ADD COLUMN theme_peer_id bigint, + ADD COLUMN host_peer_type text, + ADD COLUMN host_peer_id bigint, + ADD COLUMN offer_min_stars integer DEFAULT 0 NOT NULL, + ADD COLUMN craft_chance_permille integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL, + ADD COLUMN last_sale_currency text DEFAULT '' NOT NULL, + ADD COLUMN last_sale_amount bigint DEFAULT 0 NOT NULL, + ADD CONSTRAINT unique_star_gift_owner_check CHECK ( + (owner_peer_type IN ('user','channel') AND owner_peer_id > 0 AND owner_address='') OR + (owner_peer_type IS NULL AND owner_peer_id IS NULL AND owner_address<>'') + ), + ADD CONSTRAINT unique_star_gift_released_by_check CHECK ( + (released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR + (released_by_peer_type IN ('user','channel') AND released_by_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_theme_peer_check CHECK ( + (theme_peer_type IS NULL AND theme_peer_id IS NULL) OR + (theme_peer_type IN ('user','channel') AND theme_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_host_peer_check CHECK ( + (host_peer_type IS NULL AND host_peer_id IS NULL) OR + (host_peer_type IN ('user','channel') AND host_peer_id > 0) + ), + ADD CONSTRAINT unique_star_gift_value_check CHECK ( + value_amount >= 0 AND value_usd_amount >= 0 AND offer_min_stars >= 0 AND + craft_chance_permille BETWEEN 0 AND 1000 AND last_sale_date >= 0 AND last_sale_amount >= 0 AND + ((value_currency='' AND value_amount=0) OR value_currency<>'') AND + ((last_sale_currency='' AND last_sale_amount=0 AND last_sale_date=0) OR + (last_sale_currency IN ('XTR','TON') AND last_sale_amount>0 AND last_sale_date>0)) AND + (NOT burned OR owner_address='') AND + ((owner_address='' AND gift_address='') OR (owner_address<>'' AND gift_address<>'')) + ); + +UPDATE public.unique_star_gifts u +SET original_owner_peer_type=p.owner_peer_type, original_owner_peer_id=p.owner_peer_id +FROM public.peer_star_gifts p WHERE p.id=u.source_saved_gift_id; + +ALTER TABLE public.unique_star_gifts + ALTER COLUMN original_owner_peer_type SET NOT NULL, + ALTER COLUMN original_owner_peer_id SET NOT NULL, + ADD CONSTRAINT unique_star_gift_original_owner_check CHECK ( + original_owner_peer_type IN ('user','channel') AND original_owner_peer_id>0 + ); + +CREATE TABLE public.star_gift_listings ( + unique_gift_id bigint PRIMARY KEY REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + seller_peer_type text NOT NULL, + seller_peer_id bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + listed_at integer NOT NULL, + updated_at integer NOT NULL, + version bigint DEFAULT 1 NOT NULL, + CONSTRAINT star_gift_listing_seller_check CHECK (seller_peer_type IN ('user','channel') AND seller_peer_id>0), + CONSTRAINT star_gift_listing_amount_check CHECK (currency IN ('XTR','TON') AND amount>0 AND listed_at>0 AND updated_at>=listed_at) +); +CREATE INDEX star_gift_listings_gift_price_idx ON public.star_gift_listings(currency, amount, unique_gift_id); +CREATE INDEX star_gift_listings_updated_idx ON public.star_gift_listings(updated_at DESC, unique_gift_id DESC); + +CREATE TABLE public.star_gift_sales ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + seller_peer_type text NOT NULL, + seller_peer_id bigint NOT NULL, + buyer_peer_type text NOT NULL, + buyer_peer_id bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + commission_amount bigint DEFAULT 0 NOT NULL, + sold_at integer NOT NULL, + command_key text NOT NULL, + CONSTRAINT star_gift_sales_command_uniq UNIQUE(command_key), + CONSTRAINT star_gift_sales_peer_check CHECK ( + seller_peer_type IN ('user','channel') AND seller_peer_id>0 AND + buyer_peer_type IN ('user','channel') AND buyer_peer_id>0), + CONSTRAINT star_gift_sales_amount_check CHECK ( + currency IN ('XTR','TON') AND amount>0 AND commission_amount>=0 AND commission_amount<=amount AND sold_at>0) +); +CREATE INDEX star_gift_sales_unique_date_idx ON public.star_gift_sales(unique_gift_id, sold_at DESC); + +CREATE TABLE public.star_gift_offers ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + buyer_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + currency text NOT NULL, + amount bigint NOT NULL, + random_id bigint NOT NULL, + offer_msg_id integer DEFAULT 0 NOT NULL, + buyer_msg_id integer DEFAULT 0 NOT NULL, + status text DEFAULT 'pending' NOT NULL, + created_at integer NOT NULL, + expires_at integer NOT NULL, + resolved_at integer DEFAULT 0 NOT NULL, + balance_after bigint DEFAULT 0 NOT NULL, + expiry_notified boolean DEFAULT false NOT NULL, + CONSTRAINT star_gift_offer_random_uniq UNIQUE(buyer_user_id, random_id), + CONSTRAINT star_gift_offer_owner_msg_uniq UNIQUE(owner_peer_type, owner_peer_id, offer_msg_id), + CONSTRAINT star_gift_offer_peer_check CHECK (buyer_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0), + CONSTRAINT star_gift_offer_amount_check CHECK (currency IN ('XTR','TON') AND amount>0), + CONSTRAINT star_gift_offer_status_check CHECK (status IN ('pending','accepted','declined','expired','cancelled')), + CONSTRAINT star_gift_offer_time_check CHECK (created_at>0 AND expires_at>created_at AND resolved_at>=0 AND + ((status='pending' AND resolved_at=0) OR (status<>'pending' AND resolved_at>=created_at))) +); +CREATE INDEX star_gift_offers_pending_expiry_idx ON public.star_gift_offers(expires_at, id) WHERE status='pending'; +CREATE INDEX star_gift_offers_unique_pending_idx ON public.star_gift_offers(unique_gift_id, id) WHERE status='pending'; + +CREATE TABLE public.star_gift_transfer_commands ( + actor_user_id bigint NOT NULL, + command_key text NOT NULL, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + from_peer_type text NOT NULL, + from_peer_id bigint NOT NULL, + to_peer_type text NOT NULL, + to_peer_id bigint NOT NULL, + charge_stars bigint DEFAULT 0 NOT NULL, + balance_after bigint DEFAULT 0 NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_transfer_commands_pkey PRIMARY KEY(actor_user_id, command_key), + CONSTRAINT star_gift_transfer_command_peer_check CHECK ( + actor_user_id>0 AND from_peer_type IN ('user','channel') AND from_peer_id>0 AND + to_peer_type IN ('user','channel') AND to_peer_id>0 AND charge_stars>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_purchase_commands ( + buyer_user_id bigint NOT NULL, + command_key text NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_purchase_commands_pkey PRIMARY KEY(buyer_user_id,command_key), + CONSTRAINT star_gift_purchase_commands_form_uniq UNIQUE(buyer_user_id,form_id), + CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_prepaid_upgrade_commands ( + payer_user_id bigint NOT NULL, + command_key text NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_prepaid_upgrade_commands_pkey PRIMARY KEY(payer_user_id, command_key), + CONSTRAINT star_gift_prepaid_upgrade_commands_form_uniq UNIQUE(payer_user_id, form_id), + CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_drop_details_commands ( + user_id bigint NOT NULL, + command_key text NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + form_id bigint NOT NULL, + charge_stars bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_drop_details_commands_pkey PRIMARY KEY(user_id, command_key), + CONSTRAINT star_gift_drop_details_commands_form_uniq UNIQUE(user_id, form_id), + CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_craft_commands ( + user_id bigint NOT NULL, + command_key text NOT NULL, + input_unique_gift_ids bigint[] NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + success boolean NOT NULL, + result_unique_gift_id bigint REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + chance_permille integer NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_craft_commands_pkey PRIMARY KEY(user_id, command_key), + CONSTRAINT star_gift_craft_shape_check CHECK ( + user_id>0 AND cardinality(input_unique_gift_ids) BETWEEN 1 AND 4 AND + chance_permille BETWEEN 0 AND 1000 AND created_at>0 AND + ((success AND result_unique_gift_id IS NOT NULL) OR (NOT success AND result_unique_gift_id IS NULL))) +); + +CREATE TABLE public.star_gift_notification_settings ( + user_id bigint NOT NULL, + channel_id bigint NOT NULL, + enabled boolean DEFAULT true NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_notification_settings_pkey PRIMARY KEY(user_id, channel_id), + CONSTRAINT star_gift_notification_settings_peer_check CHECK (user_id>0 AND channel_id>0) +); + +CREATE TABLE public.star_gift_withdrawal_requests ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT, + owner_user_id bigint NOT NULL, + provider text NOT NULL, + provider_request_id text NOT NULL, + url text NOT NULL, + status text DEFAULT 'pending' NOT NULL, + created_at integer NOT NULL, + expires_at integer NOT NULL, + completed_at integer DEFAULT 0 NOT NULL, + CONSTRAINT star_gift_withdrawal_request_unique UNIQUE(unique_gift_id), + CONSTRAINT star_gift_withdrawal_provider_request_uniq UNIQUE(provider, provider_request_id), + CONSTRAINT star_gift_withdrawal_shape_check CHECK ( + owner_user_id>0 AND provider<>'' AND provider_request_id<>'' AND url<>'' AND + status IN ('pending','completed','failed') AND created_at>0 AND expires_at>created_at AND completed_at>=0) +); + +CREATE TABLE public.star_gift_auctions ( + gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + slug text NOT NULL UNIQUE, + version integer DEFAULT 1 NOT NULL, + start_date integer NOT NULL, + end_date integer NOT NULL, + round_duration integer NOT NULL, + gifts_per_round integer NOT NULL, + total_rounds integer NOT NULL, + current_round integer DEFAULT 0 NOT NULL, + next_round_at integer NOT NULL, + last_gift_num integer DEFAULT 0 NOT NULL, + gifts_left integer NOT NULL, + min_bid_amount bigint NOT NULL, + status text DEFAULT 'pending' NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_auction_shape_check CHECK ( + version>0 AND start_date>0 AND end_date>start_date AND round_duration>0 AND + gifts_per_round>0 AND total_rounds>0 AND current_round BETWEEN 0 AND total_rounds AND + next_round_at>=start_date AND last_gift_num>=0 AND gifts_left>=0 AND min_bid_amount>0 AND + status IN ('pending','active','completed','cancelled')) +); + +CREATE TABLE public.star_gift_auction_bids ( + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bidder_user_id bigint NOT NULL, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + amount bigint NOT NULL, + bid_date integer NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + returned boolean DEFAULT false NOT NULL, + acquired_count integer DEFAULT 0 NOT NULL, + active boolean DEFAULT true NOT NULL, + version bigint DEFAULT 1 NOT NULL, + CONSTRAINT star_gift_auction_bids_pkey PRIMARY KEY(gift_id, bidder_user_id), + CONSTRAINT star_gift_auction_bid_peer_check CHECK ( + bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0), + CONSTRAINT star_gift_auction_bid_amount_check CHECK (amount>0 AND bid_date>0 AND acquired_count>=0 AND version>0) +); +CREATE INDEX star_gift_auction_bids_rank_idx ON public.star_gift_auction_bids(gift_id, amount DESC, bid_date, bidder_user_id) WHERE active; + +CREATE TABLE public.star_gift_auction_bid_payments ( + user_id bigint NOT NULL, + form_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bid_amount bigint NOT NULL, + balance_after bigint NOT NULL, + created_at integer NOT NULL, + CONSTRAINT star_gift_auction_bid_payments_pkey PRIMARY KEY(user_id, form_id), + CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) +); + +CREATE TABLE public.star_gift_auction_acquired ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT, + bidder_user_id bigint NOT NULL, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + saved_gift_id bigint REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + bid_amount bigint NOT NULL, + round integer NOT NULL, + pos integer NOT NULL, + gift_num integer, + acquired_at integer NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + CONSTRAINT star_gift_auction_acquired_round_pos_uniq UNIQUE(gift_id, round, pos), + CONSTRAINT star_gift_auction_acquired_shape_check CHECK ( + bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + bid_amount>0 AND round>0 AND pos>0 AND acquired_at>0 AND (gift_num IS NULL OR gift_num>0)) +); +CREATE INDEX star_gift_auction_acquired_user_idx ON public.star_gift_auction_acquired(bidder_user_id, gift_id, id); + +CREATE TABLE public.ton_balances ( + user_id bigint PRIMARY KEY, + balance_nanoton bigint DEFAULT 0 NOT NULL, + granted boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ton_balances_check CHECK (user_id>0 AND balance_nanoton>=0) +); + +CREATE TABLE public.ton_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + amount_nanoton bigint NOT NULL, + reason text NOT NULL, + peer_type text, + peer_id bigint, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT ton_transaction_amount_check CHECK (user_id>0 AND amount_nanoton<>0 AND date>0), + CONSTRAINT ton_transaction_peer_check CHECK ( + (peer_type IS NULL AND peer_id IS NULL) OR (peer_type IN ('user','channel') AND peer_id>0)) +); +CREATE INDEX ton_transactions_user_idx ON public.ton_transactions(user_id, id DESC); + +CREATE FUNCTION public.telesrv_guard_star_gift_listing() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + gift_owner_type text; + gift_owner_id bigint; + gift_burned boolean; +BEGIN + SELECT owner_peer_type, owner_peer_id, burned + INTO gift_owner_type, gift_owner_id, gift_burned + FROM public.unique_star_gifts WHERE id=NEW.unique_gift_id FOR SHARE; + IF gift_burned OR gift_owner_type IS DISTINCT FROM NEW.seller_peer_type OR gift_owner_id IS DISTINCT FROM NEW.seller_peer_id THEN + RAISE EXCEPTION 'star gift listing owner/state mismatch'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER star_gift_listing_guard BEFORE INSERT OR UPDATE ON public.star_gift_listings + FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_star_gift_listing(); + +CREATE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + unique_id bigint; + gift_owner_type text; + gift_owner_id bigint; + gift_owner_address text; + gift_burned boolean; + saved_status text; + saved_owner_type text; + saved_owner_id bigint; +BEGIN + IF TG_TABLE_NAME = 'unique_star_gifts' THEN + unique_id := COALESCE(NEW.id, OLD.id); + ELSE + unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id); + END IF; + IF unique_id IS NULL THEN RETURN NULL; END IF; + SELECT owner_peer_type, owner_peer_id, owner_address, burned + INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned + FROM public.unique_star_gifts WHERE id=unique_id; + IF NOT FOUND THEN RETURN NULL; END IF; + SELECT lifecycle_status, owner_peer_type, owner_peer_id + INTO saved_status, saved_owner_type, saved_owner_id + FROM public.peer_star_gifts WHERE unique_gift_id=unique_id; + IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF; + IF gift_burned THEN + IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF; + ELSIF gift_owner_address <> '' THEN + IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF; + ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN + RAISE EXCEPTION 'unique star gift owner mismatch'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER unique_star_gift_owner_guard + AFTER INSERT OR UPDATE ON public.unique_star_gifts DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner(); +CREATE CONSTRAINT TRIGGER peer_unique_star_gift_owner_guard + AFTER INSERT OR UPDATE ON public.peer_star_gifts DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW WHEN (NEW.unique_gift_id IS NOT NULL) + EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner(); diff --git a/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql b/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql new file mode 100644 index 00000000..bb4bb699 --- /dev/null +++ b/deploy/migrations/0096_star_gift_lifecycle_sweeper.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS public.star_gift_auction_acquired_delivery_idx; +DROP INDEX IF EXISTS public.star_gift_auctions_due_idx; +DROP INDEX IF EXISTS public.star_gift_offers_resolution_outbox_idx; + +ALTER TABLE public.star_gift_offers + RENAME COLUMN resolution_notified TO expiry_notified; diff --git a/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql b/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql new file mode 100644 index 00000000..66b066bc --- /dev/null +++ b/deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql @@ -0,0 +1,16 @@ +-- Durable lifecycle sweep support. 0095 originally named this column after the +-- first use case (expiry); cancelled offers use the same outbox boundary. +ALTER TABLE public.star_gift_offers + RENAME COLUMN expiry_notified TO resolution_notified; + +CREATE INDEX star_gift_offers_resolution_outbox_idx + ON public.star_gift_offers(id) + WHERE status IN ('expired','cancelled') AND NOT resolution_notified; + +CREATE INDEX star_gift_auctions_due_idx + ON public.star_gift_auctions(status, next_round_at, gift_id) + WHERE status IN ('pending','active'); + +CREATE INDEX star_gift_auction_acquired_delivery_idx + ON public.star_gift_auction_acquired(gift_id, id) + WHERE saved_gift_id IS NULL; diff --git a/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql b/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql new file mode 100644 index 00000000..ff17f289 --- /dev/null +++ b/deploy/migrations/0097_star_gift_peer_stars_ledger.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS public.star_gift_conversions; +DROP TABLE IF EXISTS public.channel_stars_transactions; +DROP TABLE IF EXISTS public.channel_stars_balances; diff --git a/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql b/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql new file mode 100644 index 00000000..7e436b83 --- /dev/null +++ b/deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql @@ -0,0 +1,40 @@ +-- Owner-scoped Stars revenue for channel Star Gifts. These are internal +-- telesrv ledgers only; no blockchain, wallet, TON node or Fragment endpoint is +-- contacted. Conversion is recorded as one aggregate terminal transition. + +CREATE TABLE public.channel_stars_balances ( + channel_id bigint PRIMARY KEY, + balance bigint DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_stars_balances_shape_check CHECK (channel_id>0 AND balance>=0) +); + +CREATE TABLE public.channel_stars_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + channel_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + amount bigint NOT NULL, + reason text NOT NULL, + peer_type text DEFAULT '' NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT channel_stars_transactions_shape_check CHECK ( + channel_id>0 AND actor_user_id>0 AND amount<>0 AND date>0 AND + ((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0))) +); +CREATE INDEX channel_stars_transactions_channel_idx + ON public.channel_stars_transactions(channel_id,id DESC); + +CREATE TABLE public.star_gift_conversions ( + saved_gift_id bigint PRIMARY KEY REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT, + actor_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + amount bigint NOT NULL, + balance_after bigint NOT NULL, + converted_at integer NOT NULL, + CONSTRAINT star_gift_conversions_shape_check CHECK ( + actor_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0 AND + amount>=0 AND balance_after>=0 AND converted_at>0) +); diff --git a/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql b/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql new file mode 100644 index 00000000..77204854 --- /dev/null +++ b/deploy/migrations/0098_star_gift_channel_ton_ledger.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.channel_ton_transactions; +DROP TABLE IF EXISTS public.channel_ton_balances; diff --git a/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql b/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql new file mode 100644 index 00000000..12496203 --- /dev/null +++ b/deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql @@ -0,0 +1,25 @@ +-- TON-denominated channel marketplace proceeds remain a telesrv-local ledger. +-- No wallet, chain node, smart contract, Fragment or external RPC is involved. +CREATE TABLE public.channel_ton_balances ( + channel_id bigint PRIMARY KEY, + balance_nanoton bigint DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_ton_balances_shape_check CHECK (channel_id>0 AND balance_nanoton>=0) +); + +CREATE TABLE public.channel_ton_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + channel_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + amount_nanoton bigint NOT NULL, + reason text NOT NULL, + peer_type text DEFAULT '' NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + gift_id bigint, + date integer NOT NULL, + CONSTRAINT channel_ton_transactions_shape_check CHECK ( + channel_id>0 AND actor_user_id>0 AND amount_nanoton<>0 AND date>0 AND + ((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0))) +); +CREATE INDEX channel_ton_transactions_channel_idx + ON public.channel_ton_transactions(channel_id,id DESC); diff --git a/deploy/migrations/0099_star_gift_signed_form_ids.down.sql b/deploy/migrations/0099_star_gift_signed_form_ids.down.sql new file mode 100644 index 00000000..d34f2c3d --- /dev/null +++ b/deploy/migrations/0099_star_gift_signed_form_ids.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.star_gift_purchase_commands + DROP CONSTRAINT star_gift_purchase_command_shape_check, + ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_prepaid_upgrade_commands + DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check, + ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_drop_details_commands + DROP CONSTRAINT star_gift_drop_details_command_shape_check, + ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID; + +ALTER TABLE public.star_gift_auction_bid_payments + DROP CONSTRAINT star_gift_auction_bid_payment_shape_check, + ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) NOT VALID; diff --git a/deploy/migrations/0099_star_gift_signed_form_ids.up.sql b/deploy/migrations/0099_star_gift_signed_form_ids.up.sql new file mode 100644 index 00000000..3a160bf9 --- /dev/null +++ b/deploy/migrations/0099_star_gift_signed_form_ids.up.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.star_gift_purchase_commands + DROP CONSTRAINT star_gift_purchase_command_shape_check, + ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK ( + buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND + form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_prepaid_upgrade_commands + DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check, + ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK ( + payer_user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_drop_details_commands + DROP CONSTRAINT star_gift_drop_details_command_shape_check, + ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK ( + user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0); + +ALTER TABLE public.star_gift_auction_bid_payments + DROP CONSTRAINT star_gift_auction_bid_payment_shape_check, + ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK ( + user_id>0 AND form_id<>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0); diff --git a/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql b/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql new file mode 100644 index 00000000..ed5c6b89 --- /dev/null +++ b/deploy/migrations/0100_star_gift_upgrade_semantics.down.sql @@ -0,0 +1,35 @@ +UPDATE public.message_boxes +SET media = jsonb_set(media, '{service_action,star_gift,upgrade_stars}', media #> '{service_action,star_gift,upgrade_price_stars}', true) + #- '{service_action,star_gift,upgrade_price_stars}' +WHERE media #>> '{service_action,kind}' = 'star_gift' + AND media #> '{service_action,star_gift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_messages +SET action = jsonb_set(action, '{StarGift,upgrade_stars}', action #> '{StarGift,upgrade_price_stars}', true) + #- '{StarGift,upgrade_price_stars}' +WHERE action #>> '{Type}' = 'star_gift' + AND action #> '{StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET message = jsonb_set(message, '{Action,StarGift,upgrade_stars}', message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE message #>> '{Action,Type}' = 'star_gift' + AND message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET prev_message = jsonb_set(prev_message, '{Action,StarGift,upgrade_stars}', prev_message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE prev_message #>> '{Action,Type}' = 'star_gift' + AND prev_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET new_message = jsonb_set(new_message, '{Action,StarGift,upgrade_stars}', new_message #> '{Action,StarGift,upgrade_price_stars}', true) + #- '{Action,StarGift,upgrade_price_stars}' +WHERE new_message #>> '{Action,Type}' = 'star_gift' + AND new_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL; + +ALTER TABLE public.star_gift_upgrade_commands + DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_replay_shape_check, + DROP COLUMN IF EXISTS keep_original_details, + DROP COLUMN IF EXISTS require_prepaid, + DROP COLUMN IF EXISTS charge_stars; diff --git a/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql b/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql new file mode 100644 index 00000000..0f6d0fde --- /dev/null +++ b/deploy/migrations/0100_star_gift_upgrade_semantics.up.sql @@ -0,0 +1,79 @@ +-- Split the two TL fields that TDesktop consumes differently: +-- StarGift.upgrade_stars = current paid-upgrade price +-- messageActionStarGift.upgrade_stars = amount already prepaid by sender +-- Also persist the immutable command envelope required to replay an upgrade +-- after the saved gift has entered its unique terminal state. + +ALTER TABLE public.star_gift_upgrade_commands + ADD COLUMN charge_stars bigint NOT NULL DEFAULT 0, + ADD COLUMN require_prepaid boolean NOT NULL DEFAULT false, + ADD COLUMN keep_original_details boolean NOT NULL DEFAULT false; + +UPDATE public.star_gift_upgrade_commands c +SET charge_stars = CASE WHEN c.form_id = 0 THEN 0 ELSE r.upgrade_stars END, + require_prepaid = (c.form_id = 0), + keep_original_details = u.keep_original_details +FROM public.unique_star_gifts u +JOIN public.star_gift_collectible_revisions r ON r.id = u.collectible_revision_id +WHERE u.id = c.unique_gift_id; + +ALTER TABLE public.star_gift_upgrade_commands + ADD CONSTRAINT star_gift_upgrade_command_replay_shape_check CHECK ( + (require_prepaid AND form_id = 0 AND charge_stars = 0) + OR + (NOT require_prepaid AND form_id <> 0 AND charge_stars > 0) + ); + +-- Private message boxes are the canonical durable snapshots used by history, +-- live outbox delivery and updates.getDifference. +UPDATE public.message_boxes +SET media = CASE + WHEN COALESCE((media #>> '{service_action,star_gift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true) + ELSE jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true) + #- '{service_action,star_gift,upgrade_stars}' +END +WHERE media #>> '{service_action,kind}' = 'star_gift' + AND media #> '{service_action,star_gift,upgrade_stars}' IS NOT NULL; + +-- Channel service-message and Recent Actions snapshots use exported Go field +-- names for their outer objects and the same snake_case StarGift payload. +UPDATE public.channel_messages +SET action = CASE + WHEN COALESCE((action #>> '{StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true) + ELSE jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true) + #- '{StarGift,upgrade_stars}' +END +WHERE action #>> '{Type}' = 'star_gift' + AND action #> '{StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET message = CASE + WHEN COALESCE((message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE message #>> '{Action,Type}' = 'star_gift' + AND message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET prev_message = CASE + WHEN COALESCE((prev_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE prev_message #>> '{Action,Type}' = 'star_gift' + AND prev_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; + +UPDATE public.channel_admin_log_events +SET new_message = CASE + WHEN COALESCE((new_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false) + THEN jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true) + ELSE jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true) + #- '{Action,StarGift,upgrade_stars}' +END +WHERE new_message #>> '{Action,Type}' = 'star_gift' + AND new_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL; diff --git a/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql b/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql new file mode 100644 index 00000000..94b14285 --- /dev/null +++ b/deploy/migrations/0101_star_gift_upgrade_projection_repair.down.sql @@ -0,0 +1,3 @@ +-- The up migration emits user-visible durable update facts. Rewinding pts or +-- deleting events that may already have been delivered would create a hole in +-- updates.getDifference, so rollback intentionally preserves those facts. diff --git a/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql b/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql new file mode 100644 index 00000000..54409686 --- /dev/null +++ b/deploy/migrations/0101_star_gift_upgrade_projection_repair.up.sql @@ -0,0 +1,74 @@ +-- Migration 0100 corrected the durable Star Gift service-message JSON, but a +-- TDesktop that had already cached the old message would otherwise keep using +-- the conflated outer upgrade_stars field. Publish one durable edit event for +-- every private message box whose paid-upgrade price was split by 0100. +-- +-- This is deliberately a pts event, not a cache-only notification: history, +-- updates.getDifference and online outbox delivery must all expose the same +-- corrected message snapshot. The migration transaction keeps the message +-- pts, user watermark, durable event and dispatch task atomic. +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; +BEGIN + FOR gift_box IN + SELECT owner_user_id, box_id, peer_type, peer_id + FROM public.message_boxes + WHERE media #>> '{service_action,kind}' = 'star_gift' + AND jsonb_typeof(media #> '{service_action,star_gift,upgrade_price_stars}') = 'number' + AND (media #>> '{service_action,star_gift,upgrade_price_stars}')::bigint > 0 + ORDER BY owner_user_id, box_id + LOOP + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id; + + INSERT INTO public.user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + message_box_id, + peer_type, + peer_id + ) VALUES ( + gift_box.owner_user_id, + next_pts, + 1, + event_date, + 'edit_message', + gift_box.box_id, + gift_box.peer_type, + gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id + ) VALUES ( + gift_box.owner_user_id, + next_pts, + 'edit_message', + 0, + 0 + ); + END LOOP; +END +$$; diff --git a/deploy/migrations/0102_star_gift_purchase_forms.down.sql b/deploy/migrations/0102_star_gift_purchase_forms.down.sql new file mode 100644 index 00000000..7bc21a54 --- /dev/null +++ b/deploy/migrations/0102_star_gift_purchase_forms.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.star_gift_purchase_forms; diff --git a/deploy/migrations/0102_star_gift_purchase_forms.up.sql b/deploy/migrations/0102_star_gift_purchase_forms.up.sql new file mode 100644 index 00000000..d0dfa0c2 --- /dev/null +++ b/deploy/migrations/0102_star_gift_purchase_forms.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.star_gift_purchase_forms ( + buyer_user_id bigint NOT NULL, + form_id bigint NOT NULL, + gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT, + revision_id bigint NOT NULL REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT, + recipient_peer_type text NOT NULL, + recipient_peer_id bigint NOT NULL, + include_upgrade boolean DEFAULT false NOT NULL, + hide_name boolean DEFAULT false NOT NULL, + message text DEFAULT '' NOT NULL, + charge_stars bigint NOT NULL, + issued_at integer NOT NULL, + expires_at integer NOT NULL, + CONSTRAINT star_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id), + CONSTRAINT star_gift_purchase_form_shape_check CHECK ( + buyer_user_id > 0 AND form_id <> 0 AND gift_id > 0 AND revision_id > 0 AND + recipient_peer_type IN ('user', 'channel') AND recipient_peer_id > 0 AND + charge_stars > 0 AND issued_at > 0 AND expires_at = issued_at + 600 AND + char_length(message) <= 128) +); + +CREATE INDEX star_gift_purchase_forms_expiry_idx + ON public.star_gift_purchase_forms (expires_at, buyer_user_id, form_id); diff --git a/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql b/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql new file mode 100644 index 00000000..6bba69b1 --- /dev/null +++ b/deploy/migrations/0103_star_gift_upgrade_message_links.down.sql @@ -0,0 +1,5 @@ +-- Projection repair events are durable account history and are intentionally +-- not erased on rollback. Only remove the replay receipt column. +ALTER TABLE public.star_gift_upgrade_commands + DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_source_edit_pts_check, + DROP COLUMN IF EXISTS source_edit_pts; diff --git a/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql b/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql new file mode 100644 index 00000000..3cc23634 --- /dev/null +++ b/deploy/migrations/0103_star_gift_upgrade_message_links.up.sql @@ -0,0 +1,191 @@ +-- A user-owned upgraded gift has one stable protocol identity: the original +-- gift service-message id. The unique service message points back to it via +-- saved_id, while the original message points forward to the box-local unique +-- service message via upgrade_msg_id. Both projections must be durable pts +-- edits so history, live delivery and updates.getDifference agree. + +ALTER TABLE public.star_gift_upgrade_commands + ADD COLUMN source_edit_pts integer DEFAULT 0 NOT NULL, + ADD CONSTRAINT star_gift_upgrade_command_source_edit_pts_check CHECK (source_edit_pts >= 0); + +DO $$ +DECLARE + gift record; + source_root record; + upgrade_root record; + pair record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_source_media jsonb; + repaired_unique_media jsonb; + private_media jsonb; +BEGIN + FOR gift IN + SELECT id, owner_peer_id, msg_id, upgrade_msg_id + FROM public.peer_star_gifts + WHERE owner_peer_type = 'user' + AND unique_gift_id IS NOT NULL + AND lifecycle_status = 'active' + AND msg_id > 0 + AND upgrade_msg_id > 0 + ORDER BY id + LOOP + SELECT private_message_id, message_sender_id + INTO STRICT source_root + FROM public.message_boxes + WHERE owner_user_id = gift.owner_peer_id + AND box_id = gift.msg_id + AND NOT deleted; + + SELECT private_message_id, message_sender_id + INTO STRICT upgrade_root + FROM public.message_boxes + WHERE owner_user_id = gift.owner_peer_id + AND box_id = gift.upgrade_msg_id + AND NOT deleted; + + FOR pair IN + SELECT source_box.owner_user_id, + source_box.box_id AS source_box_id, + source_box.peer_type AS source_peer_type, + source_box.peer_id AS source_peer_id, + source_box.media AS source_media, + unique_box.box_id AS unique_box_id, + unique_box.peer_type AS unique_peer_type, + unique_box.peer_id AS unique_peer_id, + unique_box.media AS unique_media + FROM public.message_boxes source_box + JOIN public.message_boxes unique_box + ON unique_box.owner_user_id = source_box.owner_user_id + AND unique_box.message_sender_id = upgrade_root.message_sender_id + AND unique_box.private_message_id = upgrade_root.private_message_id + AND NOT unique_box.deleted + WHERE source_box.message_sender_id = source_root.message_sender_id + AND source_box.private_message_id = source_root.private_message_id + AND NOT source_box.deleted + ORDER BY source_box.owner_user_id + LOOP + IF pair.source_media #>> '{service_action,kind}' <> 'star_gift' THEN + RAISE EXCEPTION 'saved gift % source box % has invalid service action', gift.id, pair.source_box_id; + END IF; + IF pair.unique_media #>> '{service_action,kind}' <> 'star_gift_unique' THEN + RAISE EXCEPTION 'saved gift % unique box % has invalid service action', gift.id, pair.unique_box_id; + END IF; + + repaired_source_media := jsonb_set( + pair.source_media, + '{service_action,star_gift,upgrade_msg_id}', + to_jsonb(pair.unique_box_id::bigint), + true + ) #- '{service_action,star_gift,can_upgrade}'; + repaired_unique_media := jsonb_set( + pair.unique_media, + '{service_action,star_gift_unique,saved_id}', + to_jsonb(pair.source_box_id::bigint), + true + ); + + IF pair.source_media IS DISTINCT FROM repaired_source_media THEN + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (pair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = pair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_source_media, + pts = next_pts + WHERE owner_user_id = pair.owner_user_id + AND box_id = pair.source_box_id + AND NOT deleted; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + pair.owner_user_id, next_pts, 1, event_date, 'edit_message', + pair.source_box_id, pair.source_peer_type, pair.source_peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0); + + IF pair.owner_user_id = gift.owner_peer_id THEN + UPDATE public.star_gift_upgrade_commands + SET source_edit_pts = next_pts + WHERE source_saved_gift_id = gift.id; + END IF; + END IF; + + IF pair.unique_media IS DISTINCT FROM repaired_unique_media THEN + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (pair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = pair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_unique_media, + pts = next_pts + WHERE owner_user_id = pair.owner_user_id + AND box_id = pair.unique_box_id + AND NOT deleted; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + pair.owner_user_id, next_pts, 1, event_date, 'edit_message', + pair.unique_box_id, pair.unique_peer_type, pair.unique_peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0); + END IF; + END LOOP; + + SELECT media INTO STRICT private_media + FROM public.message_boxes + WHERE message_sender_id = source_root.message_sender_id + AND private_message_id = source_root.private_message_id + AND NOT deleted + ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id + LIMIT 1; + UPDATE public.private_messages + SET media = private_media + WHERE sender_user_id = source_root.message_sender_id + AND id = source_root.private_message_id; + + SELECT media INTO STRICT private_media + FROM public.message_boxes + WHERE message_sender_id = upgrade_root.message_sender_id + AND private_message_id = upgrade_root.private_message_id + AND NOT deleted + ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id + LIMIT 1; + UPDATE public.private_messages + SET media = private_media + WHERE sender_user_id = upgrade_root.message_sender_id + AND id = upgrade_root.private_message_id; + + IF EXISTS ( + SELECT 1 FROM public.star_gift_upgrade_commands + WHERE source_saved_gift_id = gift.id AND source_edit_pts <= 0 + ) THEN + RAISE EXCEPTION 'saved gift % is missing its owner source edit receipt', gift.id; + END IF; + END LOOP; +END +$$; diff --git a/deploy/migrations/0104_star_gift_craft_capability.down.sql b/deploy/migrations/0104_star_gift_craft_capability.down.sql new file mode 100644 index 00000000..186f2452 --- /dev/null +++ b/deploy/migrations/0104_star_gift_craft_capability.down.sql @@ -0,0 +1,4 @@ +-- This migration repairs invalid persisted capabilities and intentionally does +-- not restore them on downgrade: advertising Craft without an official crafted +-- model would reintroduce a user-visible operation that can never succeed. +SELECT 1; diff --git a/deploy/migrations/0104_star_gift_craft_capability.up.sql b/deploy/migrations/0104_star_gift_craft_capability.up.sql new file mode 100644 index 00000000..bc453b55 --- /dev/null +++ b/deploy/migrations/0104_star_gift_craft_capability.up.sql @@ -0,0 +1,109 @@ +-- Craft is a protocol capability, not a generic property of every collectible. +-- A non-zero craft_chance_permille is valid only when the immutable official +-- collectible revision contains at least one crafted model. Earlier versions +-- advertised the default chance for every upgrade, including official sets +-- such as Fresh Socks whose snapshot has no crafted model at all. +-- +-- Repair the aggregate and every durable private-message projection together. +-- Each visible message edit advances pts and is recoverable through both live +-- outbox delivery and updates.getDifference. +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_media jsonb; +BEGIN + UPDATE public.unique_star_gifts unique_gift + SET craft_chance_permille = 0, + updated_at = now() + WHERE unique_gift.craft_chance_permille > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ); + + UPDATE public.peer_star_gifts saved_gift + SET can_craft_at = 0 + FROM public.unique_star_gifts unique_gift + WHERE unique_gift.id = saved_gift.unique_gift_id + AND unique_gift.craft_chance_permille = 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ); + + FOR gift_box IN + SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + box.media + FROM public.message_boxes box + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') = 'number' + AND (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ) + ORDER BY box.owner_user_id, box.box_id + LOOP + repaired_media := gift_box.media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,can_craft_at}'; + + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_media, + pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id + AND NOT deleted; + + UPDATE public.private_messages + SET media = media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,can_craft_at}', + sender_snapshot = sender_snapshot + #- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{message,Media,service_action,star_gift_unique,can_craft_at}' + WHERE sender_user_id = gift_box.message_sender_id + AND id = gift_box.private_message_id; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message', + gift_box.box_id, gift_box.peer_type, gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 'edit_message', 0, 0 + ); + END LOOP; +END +$$; diff --git a/deploy/migrations/0105_star_gift_craft_projection.down.sql b/deploy/migrations/0105_star_gift_craft_projection.down.sql new file mode 100644 index 00000000..bdbe8d3b --- /dev/null +++ b/deploy/migrations/0105_star_gift_craft_projection.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE public.star_gift_craft_commands + DROP CONSTRAINT IF EXISTS star_gift_craft_source_edit_shape_check, + DROP COLUMN IF EXISTS source_edit_pts; + +-- Burned/crafted lifecycle facts and emitted edit events are authoritative +-- business history. Downgrade intentionally does not resurrect consumed gifts. +SELECT 1; diff --git a/deploy/migrations/0105_star_gift_craft_projection.up.sql b/deploy/migrations/0105_star_gift_craft_projection.up.sql new file mode 100644 index 00000000..f256c6ff --- /dev/null +++ b/deploy/migrations/0105_star_gift_craft_projection.up.sql @@ -0,0 +1,195 @@ +-- Craft outcomes consume their inputs permanently. Keep the aggregate, every +-- visible messageActionStarGiftUnique snapshot, pts/outbox delivery and command +-- replay receipt in one state model. +ALTER TABLE public.star_gift_craft_commands + ADD COLUMN source_edit_pts integer[] DEFAULT ARRAY[]::integer[] NOT NULL; + +DO $$ +DECLARE + gift_box record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; + repaired_media jsonb; +BEGIN + -- A successful legacy command would require reconstructing the crafted + -- model snapshot, not merely flipping flags. Fail fast instead of silently + -- fabricating that projection; the development database must be rebuilt or + -- repaired explicitly if such a row ever exists. + IF EXISTS (SELECT 1 FROM public.star_gift_craft_commands WHERE success) THEN + RAISE EXCEPTION 'cannot migrate legacy successful craft command without an exact crafted message projection'; + END IF; + + -- upgrade_msg_id means the current owner's unique service-message + -- projection, not permanently the first upgrade message. Ownership moves + -- replace msg_id with the new transfer/resale/offer message; repair rows + -- written before that invariant was enforced. + UPDATE public.peer_star_gifts saved_gift + SET upgrade_msg_id = saved_gift.msg_id + WHERE saved_gift.owner_peer_type = 'user' + AND saved_gift.unique_gift_id IS NOT NULL + AND saved_gift.msg_id > 0 + AND EXISTS ( + SELECT 1 + FROM public.message_boxes box + WHERE box.owner_user_id = saved_gift.owner_peer_id + AND box.box_id = saved_gift.msg_id + AND NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint = saved_gift.unique_gift_id + ); + + UPDATE public.peer_star_gifts + SET upgrade_msg_id = 0 + WHERE owner_peer_type = 'channel' + AND unique_gift_id IS NOT NULL + AND upgrade_msg_id <> 0; + + UPDATE public.unique_star_gifts + SET burned = true, + craft_chance_permille = 0, + offer_min_stars = 0, + updated_at = now() + WHERE burned; + + UPDATE public.peer_star_gifts + SET lifecycle_status = 'burned', + unsaved = true, + pinned_order = 0, + transfer_stars = 0, + can_export_at = 0, + can_transfer_at = 0, + can_resell_at = 0, + drop_original_details_stars = 0, + can_craft_at = 0 + WHERE lifecycle_status = 'burned'; + + FOR gift_box IN + SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + box.media + FROM public.message_boxes box + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND unique_gift.burned + AND ( + COALESCE((box.media #>> '{service_action,star_gift_unique,gift,Burned}')::boolean, false) = false + OR COALESCE((box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer, 0) <> 0 + OR box.media #> '{service_action,star_gift_unique,saved}' IS NOT NULL + OR box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + ) + ORDER BY box.owner_user_id, box.box_id + LOOP + repaired_media := jsonb_set( + jsonb_set( + gift_box.media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,saved}' + #- '{service_action,star_gift_unique,can_export_at}' + #- '{service_action,star_gift_unique,transfer_stars}' + #- '{service_action,star_gift_unique,resale_amount}' + #- '{service_action,star_gift_unique,can_transfer_at}' + #- '{service_action,star_gift_unique,can_resell_at}' + #- '{service_action,star_gift_unique,drop_original_details_stars}' + #- '{service_action,star_gift_unique,can_craft_at}', + '{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true); + + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (gift_box.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = gift_box.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_media, + pts = next_pts + WHERE owner_user_id = gift_box.owner_user_id + AND box_id = gift_box.box_id + AND NOT deleted; + + UPDATE public.private_messages + SET media = jsonb_set( + jsonb_set( + media + #- '{service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{service_action,star_gift_unique,saved}' + #- '{service_action,star_gift_unique,can_export_at}' + #- '{service_action,star_gift_unique,transfer_stars}' + #- '{service_action,star_gift_unique,resale_amount}' + #- '{service_action,star_gift_unique,can_transfer_at}' + #- '{service_action,star_gift_unique,can_resell_at}' + #- '{service_action,star_gift_unique,drop_original_details_stars}' + #- '{service_action,star_gift_unique,can_craft_at}', + '{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true), + sender_snapshot = jsonb_set( + jsonb_set( + sender_snapshot + #- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}' + #- '{message,Media,service_action,star_gift_unique,saved}' + #- '{message,Media,service_action,star_gift_unique,can_export_at}' + #- '{message,Media,service_action,star_gift_unique,transfer_stars}' + #- '{message,Media,service_action,star_gift_unique,resale_amount}' + #- '{message,Media,service_action,star_gift_unique,can_transfer_at}' + #- '{message,Media,service_action,star_gift_unique,can_resell_at}' + #- '{message,Media,service_action,star_gift_unique,drop_original_details_stars}' + #- '{message,Media,service_action,star_gift_unique,can_craft_at}', + '{message,Media,service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true), + '{message,Media,service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true) + WHERE sender_user_id = gift_box.message_sender_id + AND id = gift_box.private_message_id; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message', + gift_box.box_id, gift_box.peer_type, gift_box.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES ( + gift_box.owner_user_id, next_pts, 'edit_message', 0, 0 + ); + END LOOP; + + UPDATE public.star_gift_craft_commands command + SET source_edit_pts = ( + SELECT array_agg(box.pts ORDER BY input.ordinality)::integer[] AS pts + FROM unnest(command.input_unique_gift_ids) WITH ORDINALITY AS input(unique_gift_id, ordinality) + JOIN public.unique_star_gifts unique_gift ON unique_gift.id = input.unique_gift_id + JOIN public.peer_star_gifts saved_gift ON saved_gift.id = unique_gift.source_saved_gift_id + JOIN public.message_boxes box + ON box.owner_user_id = command.user_id + AND box.box_id = saved_gift.upgrade_msg_id + AND NOT box.deleted + ) + WHERE NOT command.success; + + IF EXISTS ( + SELECT 1 + FROM public.star_gift_craft_commands + WHERE cardinality(source_edit_pts) <> cardinality(input_unique_gift_ids) + OR array_position(source_edit_pts, 0) IS NOT NULL + ) THEN + RAISE EXCEPTION 'craft command is missing an exact source message edit receipt'; + END IF; +END +$$; + +ALTER TABLE public.star_gift_craft_commands + ADD CONSTRAINT star_gift_craft_source_edit_shape_check CHECK ( + cardinality(source_edit_pts) = cardinality(input_unique_gift_ids) + AND array_position(source_edit_pts, 0) IS NULL + ); diff --git a/deploy/migrations/0106_star_gift_profile_pin_order.down.sql b/deploy/migrations/0106_star_gift_profile_pin_order.down.sql new file mode 100644 index 00000000..5a0023dc --- /dev/null +++ b/deploy/migrations/0106_star_gift_profile_pin_order.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.peer_star_gifts_owner_profile_order_idx; +DROP INDEX IF EXISTS public.peer_star_gifts_owner_pinned_order_uniq; diff --git a/deploy/migrations/0106_star_gift_profile_pin_order.up.sql b/deploy/migrations/0106_star_gift_profile_pin_order.up.sql new file mode 100644 index 00000000..df76e99e --- /dev/null +++ b/deploy/migrations/0106_star_gift_profile_pin_order.up.sql @@ -0,0 +1,13 @@ +CREATE UNIQUE INDEX peer_star_gifts_owner_pinned_order_uniq + ON public.peer_star_gifts(owner_peer_type, owner_peer_id, pinned_order) + WHERE pinned_order > 0; + +CREATE INDEX peer_star_gifts_owner_profile_order_idx + ON public.peer_star_gifts( + owner_peer_type, + owner_peer_id, + (pinned_order = 0), + pinned_order, + id DESC + ) + WHERE lifecycle_status = 'active'; diff --git a/deploy/migrations/0107_account_lifecycle.down.sql b/deploy/migrations/0107_account_lifecycle.down.sql new file mode 100644 index 00000000..7e75f50d --- /dev/null +++ b/deploy/migrations/0107_account_lifecycle.down.sql @@ -0,0 +1,25 @@ +DROP TRIGGER IF EXISTS account_settings_ttl_trigger ON public.account_settings; +DROP FUNCTION IF EXISTS public.telesrv_account_settings_ttl_trigger(); +DROP TRIGGER IF EXISTS users_account_delete_at_trigger ON public.users; +DROP FUNCTION IF EXISTS public.telesrv_users_account_delete_at_trigger(); +DROP FUNCTION IF EXISTS public.telesrv_account_delete_at(timestamp with time zone, bigint, integer); + +DROP TRIGGER IF EXISTS account_passwords_changed_at_trigger ON public.account_passwords; +DROP FUNCTION IF EXISTS public.telesrv_password_changed_at_trigger(); +ALTER TABLE public.account_passwords DROP COLUMN IF EXISTS password_changed_at; + +ALTER TABLE public.account_settings + DROP CONSTRAINT account_settings_account_ttl_days_check, + ADD CONSTRAINT account_settings_account_ttl_days_check CHECK (account_ttl_days > 0); + +DROP TABLE IF EXISTS public.account_deletion_notifications; +DROP TABLE IF EXISTS public.account_deletion_requests; +DROP INDEX IF EXISTS public.dialogs_user_peer_reverse_idx; + +DROP INDEX IF EXISTS public.users_account_delete_due_idx; +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check; +ALTER TABLE public.users + DROP COLUMN IF EXISTS account_delete_at, + DROP COLUMN IF EXISTS deletion_reason, + DROP COLUMN IF EXISTS deletion_source, + DROP COLUMN IF EXISTS deleted_at; diff --git a/deploy/migrations/0107_account_lifecycle.up.sql b/deploy/migrations/0107_account_lifecycle.up.sql new file mode 100644 index 00000000..c1f0e478 --- /dev/null +++ b/deploy/migrations/0107_account_lifecycle.up.sql @@ -0,0 +1,216 @@ +-- Unified account deletion lifecycle. A deleted account remains as a minimal +-- user tombstone so historical messages keep a stable sender id, while all +-- reusable identity and profile fields are released atomically. +ALTER TABLE public.users + ADD COLUMN deleted_at timestamp with time zone, + ADD COLUMN deletion_source text DEFAULT '' NOT NULL, + ADD COLUMN deletion_reason text DEFAULT '' NOT NULL, + ADD COLUMN account_delete_at timestamp with time zone; + +ALTER TABLE public.account_passwords + ADD COLUMN password_changed_at timestamp with time zone; + +ALTER TABLE public.account_settings + DROP CONSTRAINT account_settings_account_ttl_days_check, + ADD CONSTRAINT account_settings_account_ttl_days_check + CHECK (account_ttl_days BETWEEN 1 AND 3650); + +CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END; + ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password + OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier + OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1 + OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2 + OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g + OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN + NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END; + ELSE + NEW.password_changed_at := OLD.password_changed_at; + END IF; + RETURN NEW; +END +$$; + +UPDATE public.account_passwords + SET password_changed_at = updated_at + WHERE has_password = true AND password_changed_at IS NULL; + +CREATE TRIGGER account_passwords_changed_at_trigger +BEFORE INSERT OR UPDATE ON public.account_passwords +FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger(); + +ALTER TABLE public.users + ADD CONSTRAINT users_deletion_state_check CHECK ( + (deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '') + OR + (deleted_at IS NOT NULL + AND deletion_source IN ( + 'manual', + 'forgot_password', + 'tos_decline', + 'password_reset_expiry', + 'account_ttl', + 'freeze_expiry' + ) + AND account_delete_at IS NULL + AND phone = '' + AND first_name = '' + AND last_name = '' + AND username = '' + AND country_code = '' + AND about = '' + AND verified = false + AND support = false + AND premium_expires_at IS NULL + AND emoji_status_document_id = 0 + AND emoji_status_until = 0 + AND color_set = false + AND color = 0 + AND color_background_emoji_id = 0 + AND profile_color_set = false + AND profile_color = 0 + AND profile_color_background_emoji_id = 0 + AND birthday_day = 0 + AND birthday_month = 0 + AND birthday_year = 0 + AND personal_channel_id = 0 + AND last_seen_at = 0 + AND octet_length(deletion_reason) <= 1024) + ); + +CREATE INDEX users_account_delete_due_idx + ON public.users (account_delete_at, id) + WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL; + +CREATE TABLE public.account_deletion_requests ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL REFERENCES public.users(id), + requester_auth_key_id bigint NOT NULL, + state text DEFAULT 'pending' NOT NULL, + reason text DEFAULT '' NOT NULL, + confirm_hash_digest bytea NOT NULL, + requested_at timestamp with time zone DEFAULT now() NOT NULL, + execute_at timestamp with time zone NOT NULL, + completed_at timestamp with time zone, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT account_deletion_requests_state_check CHECK ( + (state = 'pending' AND completed_at IS NULL) + OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL) + ), + CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024), + CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32) +); + +CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx + ON public.account_deletion_requests(user_id) WHERE state = 'pending'; +CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx + ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending'; +CREATE INDEX account_deletion_requests_due_idx + ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending'; + +-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge +-- queue so a crash between tombstone commit and best-effort fan-out is recovered. +-- Offline clients converge from authoritative dialog/history hydration instead +-- of an immortal retry queue or invented user pts events. +CREATE TABLE public.account_deletion_notifications ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + target_user_id bigint NOT NULL REFERENCES public.users(id), + deleted_user_id bigint NOT NULL REFERENCES public.users(id), + status text DEFAULT 'pending' NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + next_attempt_at timestamp with time zone DEFAULT now() NOT NULL, + lease_until timestamp with time zone, + last_error text DEFAULT '' NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT account_deletion_notifications_status_check + CHECK (status IN ('pending', 'dispatching', 'delivered')), + CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0), + CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id), + UNIQUE (target_user_id, deleted_user_id) +); + +CREATE INDEX account_deletion_notifications_ready_idx + ON public.account_deletion_notifications(next_attempt_at, id) + WHERE status = 'pending'; + +-- Account deletion discovers peers that have an inbound dialog row pointing at +-- the deleted user. The ordinary dialog PK only covers the owner direction; +-- keep the reverse lookup indexed so tombstoning one account never scans every +-- user's dialog table. +CREATE INDEX dialogs_user_peer_reverse_idx + ON public.dialogs(peer_id, user_id) + WHERE peer_type = 'user'; + +CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at( + p_created_at timestamp with time zone, + p_last_seen_at bigint, + p_ttl_days integer +) RETURNS timestamp with time zone +LANGUAGE sql IMMUTABLE AS $$ + SELECT GREATEST( + p_created_at, + CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END + ) + make_interval(days => p_ttl_days) +$$; + +CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger() +RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + v_ttl_days integer; +BEGIN + IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN + NEW.account_delete_at := NULL; + RETURN NEW; + END IF; + SELECT account_ttl_days INTO v_ttl_days + FROM public.account_settings WHERE user_id = NEW.id; + NEW.account_delete_at := public.telesrv_account_delete_at( + NEW.created_at, + NEW.last_seen_at, + COALESCE(v_ttl_days, 365) + ); + RETURN NEW; +END +$$; + +CREATE TRIGGER users_account_delete_at_trigger +BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot +ON public.users +FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger(); + +CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger() +RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + v_user_id bigint; + v_ttl_days integer; +BEGIN + v_user_id := COALESCE(NEW.user_id, OLD.user_id); + v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END; + UPDATE public.users + SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days), + updated_at = now() + WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false; + RETURN COALESCE(NEW, OLD); +END +$$; + +CREATE TRIGGER account_settings_ttl_trigger +AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE +ON public.account_settings +FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger(); + +UPDATE public.users u + SET account_delete_at = public.telesrv_account_delete_at( + u.created_at, + u.last_seen_at, + COALESCE(( + SELECT s.account_ttl_days + FROM public.account_settings s + WHERE s.user_id = u.id + ), 365) + ) + WHERE u.deleted_at IS NULL AND u.is_bot = false; diff --git a/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql b/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql new file mode 100644 index 00000000..3b2c0e1a --- /dev/null +++ b/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql @@ -0,0 +1 @@ +ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post; diff --git a/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql b/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql new file mode 100644 index 00000000..2ce090ed --- /dev/null +++ b/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql @@ -0,0 +1,98 @@ +ALTER TABLE channel_messages + ADD COLUMN suggested_post jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- A monoforum is a virtual per-saved-peer container, never an ordinary joined megagroup. +DELETE FROM channel_dialogs d +USING channels c +WHERE d.channel_id = c.id AND c.monoforum; + +DELETE FROM user_channel_member_index i +USING channels c +WHERE i.channel_id = c.id AND c.monoforum; + +DELETE FROM channel_members m +USING channels c +WHERE m.channel_id = c.id AND c.monoforum; + +UPDATE channels c +SET participants_count = 0, + admins_count = 0, + updated_at = now() +WHERE c.monoforum AND (c.participants_count <> 0 OR c.admins_count <> 0); + +-- Older generic sends from non-admin users are deterministically their own saved-peer dialog. +UPDATE channel_messages m +SET saved_peer_type = 'user', + saved_peer_id = m.sender_user_id +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND NOT m.deleted + AND m.saved_peer_id = 0 + AND m.action = '{}'::jsonb + AND m.sender_user_id <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM channel_members parent_member + WHERE parent_member.channel_id = mono.linked_monoforum_id + AND parent_member.user_id = m.sender_user_id + AND parent_member.status = 'active' + AND parent_member.role IN ('creator', 'admin') + ); + +UPDATE channel_update_events e +SET payload = jsonb_set( + e.payload, + '{message,SavedPeer}', + jsonb_build_object('Type', 'user', 'ID', m.sender_user_id), + true + ) +FROM channel_messages m, channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND e.channel_id = m.channel_id + AND e.message_id = m.id + AND m.saved_peer_type = 'user' + AND m.saved_peer_id = m.sender_user_id + AND COALESCE((e.payload #>> '{message,SavedPeer,ID}')::bigint, 0) = 0; + +UPDATE channel_messages m +SET send_snapshot = jsonb_set( + m.send_snapshot, + '{message,SavedPeer}', + jsonb_build_object('Type', 'user', 'ID', m.sender_user_id), + true + ) +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND m.random_id <> 0 + AND m.saved_peer_type = 'user' + AND m.saved_peer_id = m.sender_user_id + AND COALESCE((m.send_snapshot #>> '{message,SavedPeer,ID}')::bigint, 0) = 0; + +-- Remove the impossible join service message without creating a pts gap: retain the event row as noop. +UPDATE channel_update_events e +SET event_type = 'noop', message_id = 0, sender_user_id = 0, user_ids = '[]'::jsonb, payload = '{}'::jsonb +FROM channel_messages m, channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND e.channel_id = m.channel_id + AND e.message_id = m.id + AND m.action->>'Type' = 'chat_joined'; + +UPDATE channel_messages m +SET deleted = true +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND m.action->>'Type' = 'chat_joined'; + +UPDATE channels mono +SET top_message_id = COALESCE(( + SELECT max(m.id) + FROM channel_messages m + WHERE m.channel_id = mono.id AND NOT m.deleted + ), 0), + updated_at = now() +WHERE mono.monoforum; diff --git a/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql b/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql new file mode 100644 index 00000000..3efaddd3 --- /dev/null +++ b/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.channel_messages + DROP CONSTRAINT IF EXISTS channel_messages_paid_message_stars_check, + DROP COLUMN IF EXISTS paid_message_stars; diff --git a/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql b/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql new file mode 100644 index 00000000..f8dc4a06 --- /dev/null +++ b/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.channel_messages + ADD COLUMN paid_message_stars bigint NOT NULL DEFAULT 0, + ADD CONSTRAINT channel_messages_paid_message_stars_check CHECK (paid_message_stars >= 0); diff --git a/deploy/migrations/0110_bot_api_callback_queries.down.sql b/deploy/migrations/0110_bot_api_callback_queries.down.sql new file mode 100644 index 00000000..de7bebfb --- /dev/null +++ b/deploy/migrations/0110_bot_api_callback_queries.down.sql @@ -0,0 +1,20 @@ +DELETE FROM public.bot_api_updates WHERE update_kind = 'callback_query'; + +DROP INDEX IF EXISTS public.bot_api_updates_callback_query_unique; +DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_kind_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_kind_check + CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text])), + ADD CONSTRAINT bot_api_updates_source_unique + UNIQUE (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts); + +ALTER TABLE public.bot_api_updates + DROP COLUMN callback_data, + DROP COLUMN callback_chat_instance, + DROP COLUMN callback_user_id, + DROP COLUMN callback_query_id; diff --git a/deploy/migrations/0110_bot_api_callback_queries.up.sql b/deploy/migrations/0110_bot_api_callback_queries.up.sql new file mode 100644 index 00000000..ecc636a2 --- /dev/null +++ b/deploy/migrations/0110_bot_api_callback_queries.up.sql @@ -0,0 +1,35 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN callback_query_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_user_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_chat_instance bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_data bytea; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_kind_check, + DROP CONSTRAINT bot_api_updates_source_unique; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_kind_check + CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text, 'callback_query'::text])), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' + AND callback_query_id <> 0 + AND callback_user_id > 0 + AND callback_chat_instance <> 0 + AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0) + OR + (update_kind IN ('message', 'edited_message') + AND callback_query_id = 0 + AND callback_user_id = 0 + AND callback_chat_instance = 0 + AND callback_data IS NULL) + ); + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message'); + +CREATE UNIQUE INDEX bot_api_updates_callback_query_unique + ON public.bot_api_updates (bot_user_id, callback_query_id) + WHERE update_kind = 'callback_query'; diff --git a/deploy/migrations/0111_bot_api_polling_state.down.sql b/deploy/migrations/0111_bot_api_polling_state.down.sql new file mode 100644 index 00000000..646d666b --- /dev/null +++ b/deploy/migrations/0111_bot_api_polling_state.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS public.bot_api_updates_created_retention_idx; + +ALTER TABLE public.bot_api_update_states + DROP CONSTRAINT IF EXISTS bot_api_update_states_allowed_updates_check, + DROP COLUMN IF EXISTS cursor_initialized, + DROP COLUMN IF EXISTS allowed_updates; diff --git a/deploy/migrations/0111_bot_api_polling_state.up.sql b/deploy/migrations/0111_bot_api_polling_state.up.sql new file mode 100644 index 00000000..31c42386 --- /dev/null +++ b/deploy/migrations/0111_bot_api_polling_state.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.bot_api_update_states + ADD COLUMN allowed_updates text[], + ADD COLUMN cursor_initialized boolean NOT NULL DEFAULT false; + +ALTER TABLE public.bot_api_update_states + ADD CONSTRAINT bot_api_update_states_allowed_updates_check CHECK ( + allowed_updates IS NULL + OR array_position(allowed_updates, NULL) IS NULL + ); + +CREATE INDEX bot_api_updates_created_retention_idx + ON public.bot_api_updates (created_at, id); diff --git a/deploy/migrations/0112_bot_api_poll_lease.down.sql b/deploy/migrations/0112_bot_api_poll_lease.down.sql new file mode 100644 index 00000000..8cf6daeb --- /dev/null +++ b/deploy/migrations/0112_bot_api_poll_lease.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.bot_api_update_states + DROP CONSTRAINT IF EXISTS bot_api_update_states_poll_lease_check, + DROP COLUMN IF EXISTS poll_expires_at, + DROP COLUMN IF EXISTS poll_owner; diff --git a/deploy/migrations/0112_bot_api_poll_lease.up.sql b/deploy/migrations/0112_bot_api_poll_lease.up.sql new file mode 100644 index 00000000..436bef63 --- /dev/null +++ b/deploy/migrations/0112_bot_api_poll_lease.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE public.bot_api_update_states + ADD COLUMN poll_owner text NOT NULL DEFAULT '', + ADD COLUMN poll_expires_at timestamptz; + +ALTER TABLE public.bot_api_update_states + ADD CONSTRAINT bot_api_update_states_poll_lease_check CHECK ( + (poll_owner = '' AND poll_expires_at IS NULL) + OR + (poll_owner <> '' AND poll_expires_at IS NOT NULL) + ); diff --git a/deploy/migrations/0113_bot_api_inline_callbacks.down.sql b/deploy/migrations/0113_bot_api_inline_callbacks.down.sql new file mode 100644 index 00000000..bd5f671f --- /dev/null +++ b/deploy/migrations/0113_bot_api_inline_callbacks.down.sql @@ -0,0 +1,27 @@ +DELETE FROM public.bot_api_updates +WHERE update_kind = 'callback_query' AND callback_inline_message_id <> 0; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_peer_type_check, + DROP CONSTRAINT bot_api_updates_peer_id_check, + DROP CONSTRAINT bot_api_updates_message_id_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_peer_type_check CHECK (peer_type IN ('user', 'channel')), + ADD CONSTRAINT bot_api_updates_peer_id_check CHECK (peer_id > 0), + ADD CONSTRAINT bot_api_updates_message_id_check CHECK (message_id > 0), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' AND callback_query_id <> 0 AND callback_user_id > 0 + AND callback_chat_instance <> 0 AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0) + OR + (update_kind IN ('message', 'edited_message') AND callback_query_id = 0 + AND callback_user_id = 0 AND callback_chat_instance = 0 AND callback_data IS NULL) + ); + +ALTER TABLE public.bot_api_updates + DROP COLUMN callback_inline_access_hash, + DROP COLUMN callback_inline_message_id, + DROP COLUMN callback_inline_owner_id, + DROP COLUMN callback_inline_dc_id; diff --git a/deploy/migrations/0113_bot_api_inline_callbacks.up.sql b/deploy/migrations/0113_bot_api_inline_callbacks.up.sql new file mode 100644 index 00000000..24c47255 --- /dev/null +++ b/deploy/migrations/0113_bot_api_inline_callbacks.up.sql @@ -0,0 +1,49 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN callback_inline_dc_id integer NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_owner_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_message_id integer NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_access_hash bigint NOT NULL DEFAULT 0; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_peer_type_check, + DROP CONSTRAINT bot_api_updates_peer_id_check, + DROP CONSTRAINT bot_api_updates_message_id_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_peer_type_check CHECK ( + peer_type IN ('user', 'channel') + OR (update_kind = 'callback_query' AND peer_type = '') + ), + ADD CONSTRAINT bot_api_updates_peer_id_check CHECK ( + peer_id > 0 + OR (update_kind = 'callback_query' AND peer_id = 0) + ), + ADD CONSTRAINT bot_api_updates_message_id_check CHECK ( + message_id > 0 + OR (update_kind = 'callback_query' AND message_id = 0) + ), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' + AND callback_query_id <> 0 + AND callback_user_id > 0 + AND callback_chat_instance <> 0 + AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0 + AND ( + (peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0 + AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0 + AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0) + OR + (peer_type = '' AND peer_id = 0 AND message_id = 0 + AND callback_inline_dc_id > 0 AND callback_inline_owner_id > 0 + AND callback_inline_message_id > 0 AND callback_inline_access_hash <> 0) + )) + OR + (update_kind IN ('message', 'edited_message') + AND peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0 + AND callback_query_id = 0 AND callback_user_id = 0 + AND callback_chat_instance = 0 AND callback_data IS NULL + AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0 + AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0) + ); diff --git a/deploy/migrations/0114_collectible_emoji_status.down.sql b/deploy/migrations/0114_collectible_emoji_status.down.sql new file mode 100644 index 00000000..ea3bb7ec --- /dev/null +++ b/deploy/migrations/0114_collectible_emoji_status.down.sql @@ -0,0 +1,61 @@ +DROP TRIGGER IF EXISTS unique_star_gifts_clear_invalid_emoji_status ON public.unique_star_gifts; +DROP FUNCTION IF EXISTS public.telesrv_clear_invalid_collectible_emoji_status(); + +UPDATE public.documents d +SET attributes = COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' = 'custom_emoji' AND COALESCE((item->>'text_color')::boolean, false) THEN + (item - 'text_color') || jsonb_build_object('kind', 'sticker') + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) +), '[]'::jsonb) +WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns); + +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', + 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', + 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', + 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', + 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', + 'read_channel_discussion_outbox' + ]::text[]) +); +ALTER TABLE public.user_update_events DROP COLUMN IF EXISTS emoji_status_payload; + +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check; +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_emoji_status_shape_check; +ALTER TABLE public.users + DROP COLUMN IF EXISTS emoji_status_collectible, + DROP COLUMN IF EXISTS emoji_status_collectible_id; + +ALTER TABLE public.users + ADD CONSTRAINT users_deletion_state_check CHECK ( + (deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '') + OR + (deleted_at IS NOT NULL + AND deletion_source IN ( + 'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry', + 'account_ttl', 'freeze_expiry' + ) + AND account_delete_at IS NULL + AND phone = '' AND first_name = '' AND last_name = '' AND username = '' + AND country_code = '' AND about = '' AND verified = false AND support = false + AND premium_expires_at IS NULL + AND emoji_status_document_id = 0 AND emoji_status_until = 0 + AND color_set = false AND color = 0 AND color_background_emoji_id = 0 + AND profile_color_set = false AND profile_color = 0 + AND profile_color_background_emoji_id = 0 + AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0 + AND personal_channel_id = 0 AND last_seen_at = 0 + AND octet_length(deletion_reason) <= 1024) + ); diff --git a/deploy/migrations/0114_collectible_emoji_status.up.sql b/deploy/migrations/0114_collectible_emoji_status.up.sql new file mode 100644 index 00000000..58a7927e --- /dev/null +++ b/deploy/migrations/0114_collectible_emoji_status.up.sql @@ -0,0 +1,166 @@ +-- Complete collectible emoji-status state: users persist the selected unique +-- gift plus an immutable render snapshot, and the account update log persists +-- the exact status payload for online dispatch/offline difference replay. +ALTER TABLE public.users + ADD COLUMN emoji_status_collectible_id bigint REFERENCES public.unique_star_gifts(id), + ADD COLUMN emoji_status_collectible jsonb DEFAULT '{}'::jsonb NOT NULL; + +ALTER TABLE public.users + ADD CONSTRAINT users_emoji_status_shape_check CHECK ( + emoji_status_document_id >= 0 + AND emoji_status_until >= 0 + AND (emoji_status_document_id > 0 OR emoji_status_until = 0) + AND ( + ( + emoji_status_collectible_id IS NULL + AND emoji_status_collectible = '{}'::jsonb + ) OR ( + emoji_status_collectible_id IS NOT NULL + AND emoji_status_collectible_id > 0 + AND emoji_status_document_id > 0 + AND jsonb_typeof(emoji_status_collectible) = 'object' + AND emoji_status_collectible <> '{}'::jsonb + AND emoji_status_collectible ? 'collectible_id' + AND emoji_status_collectible ? 'document_id' + AND emoji_status_collectible ? 'title' + AND emoji_status_collectible ? 'slug' + AND emoji_status_collectible ? 'pattern_document_id' + AND (emoji_status_collectible->>'collectible_id')::bigint = emoji_status_collectible_id + AND (emoji_status_collectible->>'document_id')::bigint = emoji_status_document_id + AND (emoji_status_collectible->>'pattern_document_id')::bigint > 0 + AND length(emoji_status_collectible->>'title') > 0 + AND length(emoji_status_collectible->>'slug') > 0 + AND (emoji_status_collectible->>'center_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'edge_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'pattern_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'text_color')::integer BETWEEN 0 AND 16777215 + ) + ) + ); + +ALTER TABLE public.user_update_events + ADD COLUMN emoji_status_payload jsonb DEFAULT '{}'::jsonb NOT NULL; + +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages', + 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', + 'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', + 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', + 'sent_story_reaction', 'new_story_reaction', 'noop', + 'read_channel_discussion_inbox', 'read_channel_discussion_outbox' + ]::text[]) +); + +-- Android applies the collectible backdrop's pattern_color only to a +-- documentAttributeCustomEmoji with text_color=true. Existing imports stored +-- these pattern documents as ordinary stickers, so repair them in place. +UPDATE public.documents d +SET attributes = COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' = 'sticker' THEN + jsonb_set( + jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false), + '{text_color}', 'true'::jsonb, true + ) + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) +), '[]'::jsonb) +WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns); + +-- A transferred/exported/burned gift can no longer remain as the previous +-- owner's status. Keep the durable user state valid even if the lifecycle +-- mutation did not originate from an account RPC. +CREATE OR REPLACE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status() +RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + cleared_user_id bigint; + cleared_pts integer; + event_date integer; +BEGIN + event_date := EXTRACT(EPOCH FROM clock_timestamp())::integer; + FOR cleared_user_id IN + UPDATE public.users u + SET emoji_status_document_id = 0, + emoji_status_until = 0, + emoji_status_collectible_id = NULL, + emoji_status_collectible = '{}'::jsonb, + updated_at = now() + WHERE u.emoji_status_collectible_id = NEW.id + AND ( + NEW.burned + OR NEW.owner_address <> '' + OR NEW.owner_peer_type IS DISTINCT FROM 'user' + OR NEW.owner_peer_id IS DISTINCT FROM u.id + ) + RETURNING u.id + LOOP + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (cleared_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = cleared_user_id + RETURNING contiguous_pts INTO cleared_pts; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload + ) VALUES ( + cleared_user_id, cleared_pts, 1, event_date, 'user_emoji_status', + 'user', cleared_user_id, '{}'::jsonb + ); + + -- No session is the origin of a lifecycle invalidation: every online + -- device receives it, and offline devices replay the same event. + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id + ) VALUES ( + cleared_user_id, cleared_pts, 'user_emoji_status', 0, 0 + ); + END LOOP; + RETURN NEW; +END +$$; + +CREATE TRIGGER unique_star_gifts_clear_invalid_emoji_status +AFTER UPDATE OF owner_peer_type, owner_peer_id, owner_address, burned +ON public.unique_star_gifts +FOR EACH ROW EXECUTE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status(); + +-- Deleted-user tombstones must not retain the new collectible facts. +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check; +ALTER TABLE public.users + ADD CONSTRAINT users_deletion_state_check CHECK ( + (deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '') + OR + (deleted_at IS NOT NULL + AND deletion_source IN ( + 'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry', + 'account_ttl', 'freeze_expiry' + ) + AND account_delete_at IS NULL + AND phone = '' AND first_name = '' AND last_name = '' AND username = '' + AND country_code = '' AND about = '' AND verified = false AND support = false + AND premium_expires_at IS NULL + AND emoji_status_document_id = 0 AND emoji_status_until = 0 + AND emoji_status_collectible_id IS NULL + AND emoji_status_collectible = '{}'::jsonb + AND color_set = false AND color = 0 AND color_background_emoji_id = 0 + AND profile_color_set = false AND profile_color = 0 + AND profile_color_background_emoji_id = 0 + AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0 + AND personal_channel_id = 0 AND last_seen_at = 0 + AND octet_length(deletion_reason) <= 1024) + ); diff --git a/deploy/migrations/0115_bot_api_webhooks.down.sql b/deploy/migrations/0115_bot_api_webhooks.down.sql new file mode 100644 index 00000000..ba6b648d --- /dev/null +++ b/deploy/migrations/0115_bot_api_webhooks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.bot_api_webhooks; diff --git a/deploy/migrations/0115_bot_api_webhooks.up.sql b/deploy/migrations/0115_bot_api_webhooks.up.sql new file mode 100644 index 00000000..e7022039 --- /dev/null +++ b/deploy/migrations/0115_bot_api_webhooks.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE public.bot_api_webhooks ( + bot_user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + url text NOT NULL, + secret_token varchar(256) NOT NULL DEFAULT '', + max_connections integer NOT NULL DEFAULT 40, + allowed_updates text[], + failure_count integer NOT NULL DEFAULT 0, + last_error_date integer NOT NULL DEFAULT 0, + last_error_message varchar(512) NOT NULL DEFAULT '', + next_attempt_at timestamptz NOT NULL DEFAULT now(), + delivery_owner text NOT NULL DEFAULT '', + delivery_expires_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT bot_api_webhooks_url_check CHECK (length(url) BETWEEN 1 AND 2048), + CONSTRAINT bot_api_webhooks_max_connections_check CHECK (max_connections BETWEEN 1 AND 100), + CONSTRAINT bot_api_webhooks_failure_count_check CHECK (failure_count >= 0), + CONSTRAINT bot_api_webhooks_allowed_updates_check CHECK ( + allowed_updates IS NULL OR array_position(allowed_updates, NULL) IS NULL + ), + CONSTRAINT bot_api_webhooks_delivery_lease_check CHECK ( + (delivery_owner = '' AND delivery_expires_at IS NULL) + OR (delivery_owner <> '' AND delivery_expires_at IS NOT NULL) + ) +); + +CREATE INDEX bot_api_webhooks_due_idx + ON public.bot_api_webhooks (next_attempt_at, bot_user_id); diff --git a/deploy/migrations/0116_bot_requested_peer_filters.down.sql b/deploy/migrations/0116_bot_requested_peer_filters.down.sql new file mode 100644 index 00000000..8b6bf50e --- /dev/null +++ b/deploy/migrations/0116_bot_requested_peer_filters.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.webview_requested_buttons + DROP COLUMN IF EXISTS peer_filter; diff --git a/deploy/migrations/0116_bot_requested_peer_filters.up.sql b/deploy/migrations/0116_bot_requested_peer_filters.up.sql new file mode 100644 index 00000000..c07d6dce --- /dev/null +++ b/deploy/migrations/0116_bot_requested_peer_filters.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.webview_requested_buttons + ADD COLUMN peer_filter jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/deploy/migrations/0117_collectible_pattern_document_identity.down.sql b/deploy/migrations/0117_collectible_pattern_document_identity.down.sql new file mode 100644 index 00000000..b2fb149e --- /dev/null +++ b/deploy/migrations/0117_collectible_pattern_document_identity.down.sql @@ -0,0 +1,98 @@ +CREATE TEMP TABLE telesrv_pattern_document_repair_rollback ON COMMIT DROP AS +SELECT old_document_id, new_document_id +FROM public.star_gift_pattern_document_repairs; + +CREATE TEMP TABLE telesrv_rollback_collectible_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN telesrv_pattern_document_repair_rollback r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.old_document_id), + false + ), + updated_at = now() +FROM telesrv_rollback_collectible_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.old_document_id), + false + ) +FROM telesrv_pattern_document_repair_rollback r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.old_document_id +FROM telesrv_pattern_document_repair_rollback r +WHERE p.document_id = r.new_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_rollback_collectible_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_collectible_pattern_rollback_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_rollback_collectible_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_collectible_pattern_rollback_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_collectible_pattern_rollback_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_collectible_pattern_rollback_events; + +DELETE FROM public.file_blobs f +USING telesrv_pattern_document_repair_rollback r +WHERE f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%'; + +DROP TABLE public.star_gift_pattern_document_repairs; + +DELETE FROM public.documents d +USING telesrv_pattern_document_repair_rollback r +WHERE d.id = r.new_document_id; diff --git a/deploy/migrations/0117_collectible_pattern_document_identity.up.sql b/deploy/migrations/0117_collectible_pattern_document_identity.up.sql new file mode 100644 index 00000000..94d614c7 --- /dev/null +++ b/deploy/migrations/0117_collectible_pattern_document_identity.up.sql @@ -0,0 +1,172 @@ +-- Telegram clients cache Document metadata by id indefinitely. Migration 0114 +-- corrected collectible pattern attributes in place, but an Android client that +-- had already cached the old sticker shape would never observe that correction. +-- Allocate a new immutable document identity for every pre-0117 pattern, keep a +-- durable old/new map for audit and rollback, and point the published attribute +-- at the clone. The blob bytes do not change, so the new location keys alias the +-- same immutable backend object. +CREATE TABLE public.star_gift_pattern_document_repairs ( + old_document_id bigint PRIMARY KEY, + new_document_id bigint UNIQUE NOT NULL, + repaired_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_pattern_document_repairs_ids_check CHECK ( + old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id + ) +); + +INSERT INTO public.star_gift_pattern_document_repairs (old_document_id, new_document_id) +SELECT DISTINCT + p.document_id, + ((('x' || substr(md5(p.document_id::text || ':collectible-pattern-custom-emoji:v1'), 1, 16)) + ::bit(64)::bigint) & 9223372036854775807) +FROM public.star_gift_collectible_patterns p; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_document_repairs r + JOIN public.documents d ON d.id = r.new_document_id + ) THEN + RAISE EXCEPTION 'collectible pattern document repair id collision'; + END IF; +END +$$; + +INSERT INTO public.documents ( + id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes, thumbs, created_at +) +SELECT + r.new_document_id, + d.access_hash, + d.file_reference, + d.date, + d.mime_type, + d.size, + d.dc_id, + COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' IN ('sticker', 'custom_emoji') THEN + jsonb_set( + jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false), + '{text_color}', 'true'::jsonb, true + ) + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) + ), '[]'::jsonb), + d.thumbs, + now() +FROM public.star_gift_pattern_document_repairs r +JOIN public.documents d ON d.id = r.old_document_id; + +INSERT INTO public.file_blobs ( + location_key, backend, object_key, size, sha256, mime_type, created_at +) +SELECT + 'doc:' || r.new_document_id::text || + substr(f.location_key, length('doc:' || r.old_document_id::text) + 1), + f.backend, + f.object_key, + f.size, + f.sha256, + f.mime_type, + now() +FROM public.star_gift_pattern_document_repairs r +JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.old_document_id::text + OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%'; + +-- This is a repair of a previously invalid document identity, not a mutation of +-- the published appearance. Attribute id/name/animation/rarity remain intact. +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.new_document_id +FROM public.star_gift_pattern_document_repairs r +WHERE p.document_id = r.old_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +-- Capture active wearers before rewriting their immutable render snapshots. +CREATE TEMP TABLE telesrv_repaired_collectible_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN public.star_gift_pattern_document_repairs r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.new_document_id), + false + ), + updated_at = now() +FROM telesrv_repaired_collectible_wearers w +WHERE u.id = w.user_id; + +-- An offline client may still replay an older status event, so repair every +-- durable snapshot. A fresh event is appended below for clients that already +-- consumed the old pts and therefore need a new convergence edge. +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.new_document_id), + false + ) +FROM public.star_gift_pattern_document_repairs r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_repaired_collectible_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_collectible_pattern_correction_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_repaired_collectible_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_collectible_pattern_correction_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_collectible_pattern_correction_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_collectible_pattern_correction_events; diff --git a/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql b/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql new file mode 100644 index 00000000..04ef69d8 --- /dev/null +++ b/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql @@ -0,0 +1,98 @@ +CREATE TEMP TABLE telesrv_pattern_preview_repair_rollback ON COMMIT DROP AS +SELECT old_document_id, new_document_id +FROM public.star_gift_pattern_preview_document_repairs; + +CREATE TEMP TABLE telesrv_pattern_preview_rollback_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN telesrv_pattern_preview_repair_rollback r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.old_document_id), + false + ), + updated_at = now() +FROM telesrv_pattern_preview_rollback_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.old_document_id), + false + ) +FROM telesrv_pattern_preview_repair_rollback r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.old_document_id +FROM telesrv_pattern_preview_repair_rollback r +WHERE p.document_id = r.new_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_pattern_preview_rollback_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_pattern_preview_rollback_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_pattern_preview_rollback_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_pattern_preview_rollback_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_pattern_preview_rollback_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_pattern_preview_rollback_events; + +DELETE FROM public.file_blobs f +USING telesrv_pattern_preview_repair_rollback r +WHERE f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%'; + +DROP TABLE public.star_gift_pattern_preview_document_repairs; + +DELETE FROM public.documents d +USING telesrv_pattern_preview_repair_rollback r +WHERE d.id = r.new_document_id; diff --git a/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql b/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql new file mode 100644 index 00000000..ab190715 --- /dev/null +++ b/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql @@ -0,0 +1,168 @@ +-- DrKLO's profile-header path creates collectible patterns with +-- CACHE_TYPE_ALERT_PREVIEW_STATIC. For application/x-tgsticker that client +-- only loads the main TGS when Document.thumbs is non-empty; an empty list is +-- routed to a null thumbnail location and the pattern remains a flat backdrop. +-- +-- Document metadata is cached by id, so adding a thumb to an already published +-- id would not repair existing Android installations. Clone every affected +-- published pattern under a new immutable identity, add an inline PhotoPathSize +-- placeholder, alias the unchanged main blob, and converge active/durable emoji +-- status snapshots to the new id. +CREATE TABLE public.star_gift_pattern_preview_document_repairs ( + old_document_id bigint PRIMARY KEY, + new_document_id bigint UNIQUE NOT NULL, + repaired_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_pattern_preview_document_repairs_ids_check CHECK ( + old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id + ) +); + +INSERT INTO public.star_gift_pattern_preview_document_repairs (old_document_id, new_document_id) +SELECT DISTINCT + p.document_id, + ((('x' || substr(md5(p.document_id::text || ':collectible-pattern-android-preview:v2'), 1, 16)) + ::bit(64)::bigint) & 9223372036854775807) +FROM public.star_gift_collectible_patterns p +JOIN public.documents d ON d.id = p.document_id +WHERE jsonb_array_length(d.thumbs) = 0; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_preview_document_repairs r + JOIN public.documents d ON d.id = r.new_document_id + ) OR EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_preview_document_repairs r + JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%' + ) THEN + RAISE EXCEPTION 'collectible pattern preview document repair id collision'; + END IF; +END +$$; + +INSERT INTO public.documents ( + id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes, thumbs, created_at +) +SELECT + r.new_document_id, + d.access_hash, + d.file_reference, + d.date, + d.mime_type, + d.size, + d.dc_id, + d.attributes, + jsonb_build_array(jsonb_build_object( + 'kind', 'path', + 'type', 'j', + 'bytes', 'GQalBdxhTX54SARIBGNsfE4Imk4HooCjlLqhhYOHSIxMjEybVa1VkICfhqqRqquGigRYjgFNkXmHA0cGhwM=' + )), + now() +FROM public.star_gift_pattern_preview_document_repairs r +JOIN public.documents d ON d.id = r.old_document_id; + +INSERT INTO public.file_blobs ( + location_key, backend, object_key, size, sha256, mime_type, created_at +) +SELECT + 'doc:' || r.new_document_id::text || + substr(f.location_key, length('doc:' || r.old_document_id::text) + 1), + f.backend, + f.object_key, + f.size, + f.sha256, + f.mime_type, + now() +FROM public.star_gift_pattern_preview_document_repairs r +JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.old_document_id::text + OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%'; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.new_document_id +FROM public.star_gift_pattern_preview_document_repairs r +WHERE p.document_id = r.old_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +CREATE TEMP TABLE telesrv_pattern_preview_repaired_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN public.star_gift_pattern_preview_document_repairs r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.new_document_id), + false + ), + updated_at = now() +FROM telesrv_pattern_preview_repaired_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.new_document_id), + false + ) +FROM public.star_gift_pattern_preview_document_repairs r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_pattern_preview_repaired_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_pattern_preview_correction_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_pattern_preview_repaired_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_pattern_preview_correction_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_pattern_preview_correction_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_pattern_preview_correction_events; diff --git a/deploy/migrations/0119_bot_requested_peer_metadata.down.sql b/deploy/migrations/0119_bot_requested_peer_metadata.down.sql new file mode 100644 index 00000000..d132243e --- /dev/null +++ b/deploy/migrations/0119_bot_requested_peer_metadata.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.webview_requested_buttons + DROP COLUMN IF EXISTS photo_requested, + DROP COLUMN IF EXISTS username_requested, + DROP COLUMN IF EXISTS name_requested; diff --git a/deploy/migrations/0119_bot_requested_peer_metadata.up.sql b/deploy/migrations/0119_bot_requested_peer_metadata.up.sql new file mode 100644 index 00000000..e81cda0e --- /dev/null +++ b/deploy/migrations/0119_bot_requested_peer_metadata.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.webview_requested_buttons + ADD COLUMN name_requested boolean NOT NULL DEFAULT false, + ADD COLUMN username_requested boolean NOT NULL DEFAULT false, + ADD COLUMN photo_requested boolean NOT NULL DEFAULT false; diff --git a/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql b/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql new file mode 100644 index 00000000..aecbacb8 --- /dev/null +++ b/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql @@ -0,0 +1,13 @@ +DELETE FROM public.bot_api_updates +WHERE ephemeral_payload IS NOT NULL; + +DROP INDEX IF EXISTS public.bot_api_updates_ephemeral_version_unique; +DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_ephemeral_shape_check, + DROP COLUMN ephemeral_payload; + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message'); diff --git a/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql b/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql new file mode 100644 index 00000000..c3650047 --- /dev/null +++ b/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql @@ -0,0 +1,61 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN ephemeral_payload jsonb; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_ephemeral_shape_check CHECK ( + ephemeral_payload IS NULL + OR ( + peer_type = 'channel' + AND peer_id > 0 + AND message_id > 0 + AND source_pts = 0 + AND jsonb_typeof(ephemeral_payload) = 'object' + AND jsonb_typeof(ephemeral_payload -> 'Message') = 'object' + AND (ephemeral_payload #>> '{Message,ID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Peer,Type}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Peer,ID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,SenderUserID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,ReceiverUserID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Version}') IS NOT NULL + AND NOT ((ephemeral_payload -> 'Message') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ]) + AND ( + NOT (ephemeral_payload ? 'ReplyTo') + OR ( + jsonb_typeof(ephemeral_payload -> 'ReplyTo') = 'object' + AND NOT ((ephemeral_payload -> 'ReplyTo') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ]) + ) + ) + AND (ephemeral_payload #>> '{Message,ID}')::integer = message_id + AND (ephemeral_payload #>> '{Message,Peer,Type}') = peer_type + AND (ephemeral_payload #>> '{Message,Peer,ID}')::bigint = peer_id + AND ( + (update_kind = 'callback_query' + AND (ephemeral_payload #>> '{Message,SenderUserID}')::bigint = bot_user_id) + OR + (update_kind IN ('message', 'edited_message') + AND (ephemeral_payload #>> '{Message,ReceiverUserID}')::bigint = bot_user_id) + ) + AND (ephemeral_payload #>> '{Message,Version}')::bigint > 0 + ) + ); + +DROP INDEX public.bot_api_updates_message_source_unique; + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NULL; + +CREATE UNIQUE INDEX bot_api_updates_ephemeral_version_unique + ON public.bot_api_updates ( + bot_user_id, + update_kind, + peer_type, + peer_id, + message_id, + ((ephemeral_payload #>> '{Message,Version}')::bigint) + ) + WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NOT NULL; diff --git a/deploy/migrations/0121_ephemeral_abuse_reports.down.sql b/deploy/migrations/0121_ephemeral_abuse_reports.down.sql new file mode 100644 index 00000000..9c775698 --- /dev/null +++ b/deploy/migrations/0121_ephemeral_abuse_reports.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.ephemeral_abuse_reports; diff --git a/deploy/migrations/0121_ephemeral_abuse_reports.up.sql b/deploy/migrations/0121_ephemeral_abuse_reports.up.sql new file mode 100644 index 00000000..9dc4e73c --- /dev/null +++ b/deploy/migrations/0121_ephemeral_abuse_reports.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.ephemeral_abuse_reports ( + id bigserial PRIMARY KEY, + reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0), + channel_id bigint NOT NULL CHECK (channel_id > 0), + ephemeral_message_id integer NOT NULL CHECK (ephemeral_message_id > 0), + sender_user_id bigint NOT NULL CHECK (sender_user_id > 0), + receiver_user_id bigint NOT NULL CHECK (receiver_user_id = reporter_user_id), + report_option text NOT NULL CHECK (length(report_option) BETWEEN 1 AND 64), + report_comment text NOT NULL DEFAULT '' CHECK (length(report_comment) <= 4096), + comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32), + payload_hash bytea NOT NULL CHECK (octet_length(payload_hash) = 32), + evidence jsonb NOT NULL CHECK (jsonb_typeof(evidence) = 'object'), + created_at timestamptz NOT NULL, + CONSTRAINT ephemeral_abuse_reports_idempotency UNIQUE ( + reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash + ) +); + +CREATE INDEX ephemeral_abuse_reports_created_at_idx + ON public.ephemeral_abuse_reports (created_at DESC, id DESC); + +CREATE INDEX ephemeral_abuse_reports_sender_created_idx + ON public.ephemeral_abuse_reports (sender_user_id, created_at DESC, id DESC); diff --git a/deploy/migrations/0122_communities.down.sql b/deploy/migrations/0122_communities.down.sql new file mode 100644 index 00000000..38cc8fac --- /dev/null +++ b/deploy/migrations/0122_communities.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; +ALTER TABLE public.user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user','channel')); + +DELETE FROM public.notify_settings WHERE scope_kind='peer' AND peer_type='community'; + +DROP TRIGGER IF EXISTS users_linked_community_read_model_changed ON public.users; +DROP FUNCTION IF EXISTS public.telesrv_notify_user_linked_community_read_model(); + +DROP INDEX IF EXISTS public.channels_linked_community_idx; +DROP INDEX IF EXISTS public.users_linked_community_idx; +ALTER TABLE public.channels DROP COLUMN IF EXISTS linked_community_id; +ALTER TABLE public.users DROP COLUMN IF EXISTS linked_community_id; +DROP TABLE IF EXISTS public.community_user_states; +DROP TABLE IF EXISTS public.community_peer_link_requests; +DROP TABLE IF EXISTS public.community_peer_links; +DROP TABLE IF EXISTS public.community_members; +DROP TABLE IF EXISTS public.communities; diff --git a/deploy/migrations/0122_communities.up.sql b/deploy/migrations/0122_communities.up.sql new file mode 100644 index 00000000..a61d2d38 --- /dev/null +++ b/deploy/migrations/0122_communities.up.sql @@ -0,0 +1,130 @@ +-- Layer 228 Communities are aggregation containers. Linked peer dialogs keep +-- their own messages/read state/pts; these tables only persist the container, +-- links, moderation, pending requests and per-user dialog presentation. +ALTER TABLE public.user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; +ALTER TABLE public.user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user','channel','community')); + +CREATE TABLE public.communities ( + id bigint PRIMARY KEY, + access_hash bigint NOT NULL, + creator_user_id bigint NOT NULL REFERENCES public.users(id), + title text NOT NULL, + about text DEFAULT ''::text NOT NULL, + default_banned_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + photo_id bigint DEFAULT 0 NOT NULL, + photo_dc_id integer DEFAULT 0 NOT NULL, + photo_stripped bytea DEFAULT '\x'::bytea NOT NULL, + date integer NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT communities_positive_check CHECK (id > 0 AND creator_user_id > 0), + CONSTRAINT communities_title_check CHECK (length(btrim(title)) > 0) +); + +CREATE UNIQUE INDEX communities_access_hash_idx ON public.communities(access_hash); +CREATE INDEX communities_creator_idx ON public.communities(creator_user_id, id) WHERE NOT deleted; + +CREATE TABLE public.community_members ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES public.users(id), + role text DEFAULT 'member'::text NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + admin_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + rank text DEFAULT ''::text NOT NULL, + date integer NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, user_id), + CONSTRAINT community_members_role_check CHECK (role IN ('creator','admin','member')), + CONSTRAINT community_members_status_check CHECK (status IN ('active','kicked')) +); + +CREATE UNIQUE INDEX community_one_creator_idx + ON public.community_members(community_id) WHERE role = 'creator' AND status = 'active'; +CREATE INDEX community_members_user_idx ON public.community_members(user_id, community_id) WHERE status = 'active'; +CREATE INDEX community_members_kicked_idx ON public.community_members(community_id, user_id) WHERE status = 'kicked'; + +CREATE TABLE public.community_peer_links ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + visibility text NOT NULL, + created_by bigint NOT NULL REFERENCES public.users(id), + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, peer_type, peer_id), + CONSTRAINT community_peer_links_type_check CHECK (peer_type IN ('channel','user')), + CONSTRAINT community_peer_links_visibility_check CHECK (visibility IN ('visible','hidden')), + CONSTRAINT community_peer_links_positive_check CHECK (peer_id > 0 AND created_by > 0) +); + +-- A group/channel/bot may belong to only one Community. +CREATE UNIQUE INDEX community_peer_links_unique_peer_idx ON public.community_peer_links(peer_type, peer_id); +CREATE INDEX community_peer_links_community_idx ON public.community_peer_links(community_id, date, peer_type, peer_id); + +CREATE TABLE public.community_peer_link_requests ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + requested_by bigint NOT NULL REFERENCES public.users(id), + visibility text NOT NULL, + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, peer_type, peer_id), + CONSTRAINT community_peer_requests_type_check CHECK (peer_type IN ('channel','user')), + CONSTRAINT community_peer_requests_visibility_check CHECK (visibility IN ('visible','hidden')), + CONSTRAINT community_peer_requests_positive_check CHECK (peer_id > 0 AND requested_by > 0) +); + +CREATE INDEX community_peer_requests_page_idx + ON public.community_peer_link_requests(community_id, date DESC, peer_type, peer_id); + +CREATE TABLE public.community_user_states ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES public.users(id), + collapsed boolean DEFAULT false NOT NULL, + pinned boolean DEFAULT false NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, user_id), + CONSTRAINT community_user_states_order_check CHECK (pinned_order >= 0) +); + +CREATE INDEX community_user_states_pinned_idx + ON public.community_user_states(user_id, pinned_order, community_id) WHERE pinned; + +ALTER TABLE public.users + ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL; +ALTER TABLE public.channels + ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL; + +CREATE INDEX users_linked_community_idx ON public.users(linked_community_id) WHERE linked_community_id <> 0; +CREATE INDEX channels_linked_community_idx ON public.channels(linked_community_id) WHERE linked_community_id <> 0; + +-- linked_community_id is part of the Layer 228 user projection. The legacy +-- user-base trigger intentionally lists projected columns and therefore does +-- not notice this newly-added column; emit the same read-model bumps here so +-- Redis/base-user and contact projections cannot retain a stale bot link. +CREATE FUNCTION public.telesrv_notify_user_linked_community_read_model() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM telesrv_bump_read_model_version('user_base', NEW.id, 'user', NEW.id); + PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id) + FROM contacts c + WHERE c.contact_user_id = NEW.id; + PERFORM telesrv_bump_private_dialog_light_for_user(NEW.id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER users_linked_community_read_model_changed +AFTER UPDATE OF linked_community_id ON public.users +FOR EACH ROW +WHEN (OLD.linked_community_id IS DISTINCT FROM NEW.linked_community_id) +EXECUTE FUNCTION public.telesrv_notify_user_linked_community_read_model(); + +-- Community notification settings reuse the existing peer-scoped table with a +-- distinct peer_type. No new scope_kind is required. diff --git a/docs/configuration.en.md b/docs/configuration.en.md new file mode 100644 index 00000000..20f35c0b --- /dev/null +++ b/docs/configuration.en.md @@ -0,0 +1,244 @@ +# telesrv configuration reference + +Chinese version: [configuration.zh-CN.md](configuration.zh-CN.md) + +This document describes every setting loaded by `internal/config`. Defaults and validation behavior in `internal/config/config.go` are authoritative. All settings require a process restart; telesrv does not hot-reload configuration. + +## 1. Loading, syntax, and precedence + +- `TELESRV_CONFIG` is a **process environment variable** selecting the env-style configuration file. Default: `.env` in the process working directory. An explicit empty value disables file loading. Setting it inside the file has no effect because the file has already been selected. +- Precedence is: non-empty process environment value → non-empty file value → code default. The nullable listener settings (`TELESRV_DEBUG_ADDR`, `TELESRV_BOT_API_ADDR`, `TELESRV_ADMIN_API_ADDR`, and `TELESRV_PUBLIC_LINK_WEB_ADDR`) additionally allow an explicitly empty process value to disable a non-empty file value. +- The file accepts blank lines, full-line `#` comments, optional `export `, and `KEY=VALUE`. Single- and double-quoted values are supported. Inline comments are not stripped. +- File keys must start with `TELESRV_` and contain only uppercase ASCII letters, digits, and underscores. Unknown `TELESRV_*` keys are syntactically accepted but ignored by the current binary. +- Booleans accept `1/true/TRUE/True/yes/on` and `0/false/FALSE/False/no/off`. Lists are comma-separated. Durations use Go duration syntax such as `200ms`, `30s`, `5m`, or `168h`. +- Invalid integer, float, boolean, or duration text falls back to the code default. URL, app-scheme, app-name, and login-email dependency validation fails startup instead. +- Never commit real passwords, tokens, private DSNs, or TURN secrets. Prefer a secret manager or protected service environment in production. + +## 2. MTProto listener, transport, and resource budgets + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_LISTEN` | string / `0.0.0.0:2398` | MTProto TCP listen address. Must match the address/port reachable by patched clients. | +| `TELESRV_ADVERTISE_IP` | string / `127.0.0.1` | Client-reachable server IP used by media/call fallbacks. The current static Desktop DC patch does not derive its MTProto endpoint from this value. | +| `TELESRV_RSA_KEY` | path / `data/server_rsa.pem` | MTProto RSA private key. Generated when missing. Treat the file as a secret and keep it stable across restarts. | +| `TELESRV_DC` | int / `2` | Server DC ID. Must match patched client expectations and stored media/DC metadata. | +| `TELESRV_WEBSOCKET_ENABLE` | bool / `true` | Enables MTProto-over-WebSocket demultiplexing on the MTProto listener. | +| `TELESRV_WEBSOCKET_ALLOWED_ORIGINS` | list / `http://localhost:1234,http://127.0.0.1:1234` | Browser WebSocket origin allow-list. `*` is for temporary debugging only. | +| `TELESRV_MTPROTO_MAX_CONNECTIONS` | int / `200000` | Global physical connection admission limit. Negative disables this gate. | +| `TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP` | int / `4096` | Per-source-IP physical connection limit. Negative disables this gate. | +| `TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES` | int / `256` | Concurrent expensive RSA/DH handshakes. Negative disables this gate. | +| `TELESRV_MTPROTO_RPC_MAX_INFLIGHT` | int / `32` | Per-connection concurrent RPC budget; non-positive values are normalized by the edge to its safe default. | +| `TELESRV_MTPROTO_RPC_QUEUE_SIZE` | int / `64` | Per-connection queued RPC budget; non-positive values use the edge default. | +| `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | End-to-end handler timeout for scheduled RPC work. | +| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | Shared fair-scheduler worker count. | +| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | Process-wide scheduled/in-flight RPC task cap. | +| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide queued/in-flight RPC request-body budget. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES` | int / `262144` | Global ownership entries for pending owners, completed results, and tombstones during the in-process 331-second replay window. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES` | int64 bytes / `67108864` | Global retained-byte budget. Owner admission reserves one byte; Put transfers it to a body or tombstone. Must be at least `16775168`. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES` | int / `32768` | Per raw-auth-key ownership entries; charged together with global and session scopes. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES` | int64 bytes / `33554432` | Per raw-auth-key retained bytes. Limits must satisfy `global >= auth >= session`. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES` | int / `16384` | Per `raw auth key + session_id` ownership entries. | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES` | int64 bytes / `16777216` | Per `raw auth key + session_id` retained bytes; large enough for one legal outbound body. | +| `TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH` | int / `2048` | Additional active-owner cap per raw auth key; no greater than global pending tasks or auth entries. | +| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide reservation for transport wire bytes plus maximum decrypted plaintext, acquired before payload allocation. | +| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | Per-connection normal outbound mailbox capacity. | +| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | Per-connection control-message mailbox capacity. | +| `TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Global budget for tracked resend-pending message bodies. | +| `TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Global budget for concurrent encrypted wire/codec/obfuscation scratch. | + +## 3. HTTP endpoints, public links, and administration + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug listener. Empty disables it. Keep loopback-only; use an SSH tunnel for production profiling. | +| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. | +| `TELESRV_ADMIN_API_ADDR` | nullable address / empty | In-process Admin write API listener. Empty disables it; production should bind loopback. | +| `TELESRV_ADMIN_API_TOKEN` | secret string / empty | Admin API bearer token. Required when the Admin API is enabled and must match the Admin UI token configuration. | +| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | Standalone `cmd/telesrv-admin` listen address. | +| `TELESRV_ADMIN_UI_PASSWORD` | secret string / empty | Admin UI login password. Configure this or `TELESRV_ADMIN_UI_TOKEN`. | +| `TELESRV_ADMIN_UI_TOKEN` | secret string / empty | Alternative Admin UI login credential. Admin write calls still use the separate `TELESRV_ADMIN_API_TOKEN`. | +| `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. | +| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. | +| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. | +| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. | +| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. | +| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. | + +## 4. PostgreSQL, Redis, files, and seed data + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_POSTGRES_DSN` | secret DSN / `postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable` | Primary durable business database. Production must replace the development credentials and TLS policy. | +| `TELESRV_POSTGRES_MAX_CONNS` | int / `50` | pgxpool maximum connections. `<=0` delegates to pgx defaults, which are usually too small for production outbox/RPC concurrency. | +| `TELESRV_POSTGRES_MIN_CONNS` | int / `16` | pgxpool pre-warmed minimum connections. | +| `TELESRV_REDIS_ADDR` | address / `127.0.0.1:6399` | Redis used for volatile codes, limits, and shared update/cache state. | +| `TELESRV_REDIS_PASSWORD` | secret string / empty | Redis password. | +| `TELESRV_REDIS_DB` | int / `0` | Redis logical database number. | +| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` language-pack seed directory. | +| `TELESRV_OFFICIAL_GIFTS_DIR` | path / `data/official-gifts` | Read-only snapshot generated by `cmd/giftfetch`, used for verified explicit imports in the admin UI. | +| `TELESRV_BLOB_DIR` | path / `data/blobs` | Local development blob-backend root for media bytes. | +| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. | +| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. | + +The language-pack file manifest is authoritative. To add a language, place `data/langpack//__v.strings` and restart `telesrv`. The `pack` must match its first-level directory and may use the letters, digits, `-`, and `_` already used by Telegram (for example, `android_x`); `lang` is canonicalized to lowercase with hyphens (`pt_BR` becomes `pt-br`). Only the highest file version for each language is loaded. Effective content changes require a version bump; same-version effective mutations and version rollbacks stop startup. Removing a language file or an entire pack subdirectory atomically removes its database catalog and strings on the next restart. Startup streams a source-file SHA-256 first: unchanged files reuse the last atomic manifest without parsing strings or writing the database, while only new or changed files are parsed and replaced through PostgreSQL `COPY`. + +## 5. Authentication, OTP providers, SMTP, and passkeys + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | Fixed code used by `PHONE_CODE_DELIVERY_PROVIDER=development`; do not expose this default publicly. | +| `TELESRV_AUTH_CODE_TTL` | duration / `5m` | Login/registration/email verification code lifetime; must be positive. | +| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | Maximum wrong attempts for one code/hash; must be positive. | +| `TELESRV_PHONE_CODE_LENGTH` | int / `5` | Random SMS-code length for the `webhook` phone provider; allowed range `4..10`. | +| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | Code issuance limit per normalized phone digest per rate window; `<=0` disables this dimension. | +| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | Code issuance limit per raw auth key per rate window; `<=0` disables this dimension. | +| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. | +| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. | +| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Must use `http`/`https` and contain no userinfo. | +| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / empty | Optional HMAC-SHA256 signing secret; enables `X-Telesrv-Signature` when non-empty. | +| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP timeout; must be positive when Webhook delivery is enabled. | +| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification. SMTP settings are required only when the email provider is `smtp`. | +| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | Forces accounts without a login email to configure one. Requires `TELESRV_LOGIN_EMAIL_ENABLE=true`. | +| `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | Email verification-code length; allowed range `4..10`. | +| `TELESRV_SMTP_HOST` | string / empty | SMTP server host; required when login email is enabled with the `smtp` provider. | +| `TELESRV_SMTP_PORT` | int / `587` | SMTP port; must be `1..65535` when the SMTP provider is used. | +| `TELESRV_SMTP_USERNAME` | sensitive string / empty | SMTP username. Also used as sender when `TELESRV_SMTP_FROM` is empty. | +| `TELESRV_SMTP_PASSWORD` | secret string / empty | SMTP password. | +| `TELESRV_SMTP_FROM` | email/string / empty | Envelope/header sender. Either this or SMTP username is required when login email is enabled. | +| `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | Display name for login-email messages. | +| `TELESRV_SMTP_TLS` | enum / `starttls` | `starttls`, `tls`, or `none`; any other value fails startup. | +| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP operation timeout; must be positive when the SMTP provider is used. | +| `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID used for `rpIdHash`. Android Credential Manager requires alignment with hosted `assetlinks.json`. | +| `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / empty | Allowed WebAuthn origins. Empty disables explicit origin enforcement because Android APK-key-hash origins may not be known in advance. | + +## 6. Maps, external media, previews, and uploads + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_MAPBOX_TOKEN` | secret string / empty | Mapbox Static Images access token for `upload.getWebFile` map previews. Empty uses deterministic placeholders. | +| `TELESRV_MAPTILE_CACHE_DIR` | path / `data/maptiles` | Disk cache for fetched map thumbnails, preserving byte-stable chunk downloads and limiting quota use. | +| `TELESRV_EXTERNAL_MEDIA_ENABLE` | bool / `true` | Enables SSRF-protected fetching of external photo/document URLs. | +| `TELESRV_EXTERNAL_MEDIA_MAX_BYTES` | int bytes / `10485760` | Maximum response body per external-media fetch. Downstream treats `<=0` as the 10 MiB safe default. | +| `TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN` | int / `60` | Global external-media fetches per minute. Downstream treats `<=0` as its default. | +| `TELESRV_WEBPAGE_PREVIEW_ENABLE` | bool / `true` | Enables SSRF-protected Web-page metadata/image fetching for link previews. | +| `TELESRV_WEBPAGE_PREVIEW_MAX_BYTES` | int bytes / `5242880` | Response cap shared by preview HTML and image fetching. Downstream treats `<=0` as the 5 MiB default. | +| `TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN` | int / `300` | Global preview upstream requests per minute; one preview may make at most two requests. | +| `TELESRV_UPLOAD_PART_TTL` | duration / `24h` | Retention for unassembled upload parts. | +| `TELESRV_UPLOAD_PART_GC_INTERVAL` | duration / `30m` | Upload-part GC polling interval. | +| `TELESRV_UPLOAD_PART_GC_BATCH` | int / `10000` | Maximum rows removed per upload-part GC batch. | +| `TELESRV_UPLOAD_INFLIGHT_MAX_BYTES` | int64 bytes / `4194304000` | Per-user unassembled upload-byte cap; `<=0` means unlimited. | +| `TELESRV_UPLOAD_INFLIGHT_MAX_PARTS` | int / `8000` | Per-user unassembled upload-part row cap; `<=0` means unlimited. | +| `TELESRV_UPLOAD_INFLIGHT_MAX_FILES` | int / `64` | Per-user concurrent unassembled `file_id` cap; `<=0` means unlimited. | + +## 7. AI compose and business automation + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business auto-reply generator. Allowed values are `echo`/empty (echo the triggering text), `template`/`quick_reply`/`quick-reply` (use quick-reply templates), or `ai`/`compose_ai`/`ai_compose`/`aicompose`/`kimi` (reuse the `TELESRV_AI_PROVIDERS` provider chain). This setting does not accept arbitrary provider names; for example, with Ollama set `TELESRV_BUSINESS_AI_PROVIDER=ai` and select the actual provider through `TELESRV_AI_PROVIDERS=ollama,local`. | +| `TELESRV_AI_ENABLED` | bool / `true` | Enables client compose rewrite/polish. False returns no tones and hides the entry. | +| `TELESRV_AI_PROVIDERS` | list / `local` | Ordered provider chain. Empty resolves to deterministic `local`, which makes no external request. | +| `TELESRV_AI_TIMEOUT` | duration / `15s` | Total timeout for one provider call. | +| `TELESRV_AI_RATE_LIMIT` | int / `20` | Per-account compose operations per window. | +| `TELESRV_AI_RATE_WINDOW` | duration / `1m` | Compose AI rate-limit window. | +| `TELESRV_AI_LOG_CONTENT` | bool / `false` | When false, logs contain lengths/provider/status only. Enabling may expose user prompts and generated text. | +| `TELESRV_TRANSLATION_ENABLED` | bool / `true` | Enables `messages.translateText`; at least one remote AI provider is still required, and the local echo provider is never treated as translation. | +| `TELESRV_TRANSLATION_PROVIDERS` | list / empty | Selects provider names from `TELESRV_AI_PROVIDERS`; empty uses every configured remote provider. | +| `TELESRV_TRANSLATION_TIMEOUT` | duration / `15s` | Total timeout for one batch; batches contain at most 20 texts and use fixed provider concurrency of 4. | +| `TELESRV_TRANSLATION_RATE_LIMIT` | int / `60` | Per-account translated text items per window; a 20-item batch costs 20 to prevent provider-call amplification. | +| `TELESRV_TRANSLATION_RATE_WINDOW` | duration / `1m` | Translation rate-limit window. | + +Chat translation sends message bodies explicitly selected by the user to the configured external provider. Default logs omit content, but deployments should still disclose the upstream processor in their privacy policy. With only `local` configured, telesrv returns `TRANSLATIONS_DISABLED` instead of presenting source text as a translation. + +For each name in `TELESRV_AI_PROVIDERS`, telesrv uppercases it, converts non-alphanumeric characters to `_`, and reads the following dynamic keys. Example: provider `openai-compatible` uses suffix `OPENAI_COMPATIBLE`. + +| Dynamic setting | Type / default | Description | +|---|---|---| +| `TELESRV_AI__KIND` | string / derived from name | Adapter kind. Built-ins: `local`, `openai_responses`, `openai_chat`, `gemini`, `anthropic`. Names `openai`, `openai_chat`/`openai-compatible`/`openai_compat`, `gemini`, and `anthropic` map to their corresponding built-in kind. | +| `TELESRV_AI__BASE_URL` | URL string / empty | Optional provider endpoint override. Required by some compatible/self-hosted providers. | +| `TELESRV_AI__API_KEY` | secret string / provider fallback | Provider credential. For known providers it falls back to the process variables below. | +| `TELESRV_AI__MODEL` | string / empty | Provider model identifier. External providers generally require it. | +| `TELESRV_AI__MAX_OUTPUT_TOKENS` | int / `1024` | Requested output-token cap. | +| `TELESRV_AI__TEMPERATURE` | float / `0.2` | Sampling temperature. | +| `TELESRV_AI__OMIT_TEMPERATURE` | bool / `false` | Omits the temperature field for models/providers that reject it. | +| `TELESRV_AI__THINKING` | string / empty | Provider-specific thinking/reasoning mode, normalized to lowercase; for example `disabled`. | + +The following fallback keys are accepted from the **process environment only**. The env file rejects them because they do not start with `TELESRV_`: `OPENAI_API_KEY`, `GEMINI_API_KEY`, and `ANTHROPIC_API_KEY`. A provider-specific `TELESRV_AI__API_KEY` takes precedence. + +## 8. Read-model and auth-key caches + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES` | int / `262144` | Router temporary→permanent auth-key binding cache capacity. | +| `TELESRV_TEMP_KEY_CACHE_TTL` | duration / `30m` | Recheck period; exact bind/revoke invalidation handles normal writes, while TTL covers cross-process/exception paths. | +| `TELESRV_CHANNEL_ROW_CACHE_MAX` | int / `50000` | Shared channel-row cache capacity. `<=0` disables both cache and its LISTEN/NOTIFY listener. | +| `TELESRV_CHANNEL_MEMBER_CACHE_MAX` | int / `100000` | Channel member/access read-model cache capacity; `<=0` disables it. | +| `TELESRV_CHANNEL_DIALOG_CACHE_MAX` | int / `100000` | Viewer/channel dialog projection cache capacity; `<=0` disables it. | +| `TELESRV_CHANNEL_BOOST_CACHE_MAX` | int / `100000` | Channel boost read-model cache capacity; `<=0` disables it. | +| `TELESRV_CHANNEL_BOOST_CACHE_TTL` | duration / `10s` | Maximum stale window if a boost invalidation notification is missed. | + +## 9. Outbox, push, limits, retention, and GC + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_OUTBOX_WORKERS` | int / `4` | Concurrent outbox workers. Stable logical sharding preserves per-user pts order. | +| `TELESRV_OUTBOX_BATCH` | int / `100` | Maximum rows claimed per poll. Larger batches improve throughput but increase DB/push bursts. | +| `TELESRV_OUTBOX_INTERVAL` | duration / `200ms` | Delay between outbox claims. | +| `TELESRV_OUTBOX_LEASE_TIMEOUT` | duration / `30s` | Time before a `dispatching` row can be reclaimed. Must exceed worst-case batch delivery time. | +| `TELESRV_OUTBOX_POISON_RETENTION` | duration / `1m` | Diagnostic retention for terminal failed delivery heads; durable update events remain recoverable through difference. | +| `TELESRV_OUTBOX_POISON_CLEANUP_INTERVAL` | duration / `15s` | Cleanup interval for terminal failed heads, independent of large-table retention. | +| `TELESRV_OUTBOUND_PUSH_TIMEOUT` | duration / `200ms` | Maximum wait for best-effort online update enqueue. | +| `TELESRV_SEND_RATE_LIMIT` | int / `30` | Per-account messages per send window; `<=0` disables send limiting. | +| `TELESRV_SEND_RATE_WINDOW` | duration / `1m` | Send-rate window. | +| `TELESRV_CATCHUP_RATE_LIMIT` | int / `0` | Per-user difference/catch-up RPCs per window; `<=0` disables the gate. | +| `TELESRV_CATCHUP_RATE_WINDOW` | duration / `1m` | Catch-up rate-limit window. | +| `TELESRV_CHANNEL_NUDGE_MAX_TARGETS` | int / `0` | Maximum targets for one channel fan-out nudge; `<=0` uses the built-in default. | +| `TELESRV_UPDATE_EVENT_RETENTION` | duration / `168h` | Durable update-log retention. Cleanup only removes events covered by protocol-safe watermarks/state. | +| `TELESRV_BOT_API_UPDATE_RETENTION` | duration / `24h` | Maximum Bot API update queue retention; acknowledged rows also have a shorter fixed grace period. | +| `TELESRV_ORPHAN_AUTH_KEY_RETENTION` | duration / `24h` | Minimum retention for handshake-created keys with no authorization/temp binding/active connection. | +| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | General retention worker interval. | +| `TELESRV_RETENTION_BATCH` | int / `10000` | Maximum rows deleted by one general retention batch. | + +## 10. Premium and Stars development grants + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_PREMIUM_GRANT_MONTHS` | int / `3` | Premium months granted to newly registered users; `0` disables new grants. Existing migration backfills are unaffected. | +| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | Idempotent lazy starting Stars balance for all accounts; `0` disables automatic grant. | +| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | Expired-premium cleanup/push interval. Read paths derive expiry independently. | +| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | Maximum expired premium rows processed per sweep. | + +## 11. Private calls, group calls, TURN, SFU, and livestream + +| Setting | Type / code default | Description and constraints | +|---|---|---| +| `TELESRV_CALL_RING_TIMEOUT` | duration / `90s` | Server fallback timeout for ringing/accepted private calls; should remain aligned with the client `callRingTimeoutMs`. | +| `TELESRV_CALL_TOMBSTONE_TTL` | duration / `60s` | Terminal-call tombstone window for idempotency and late RPC absorption. | +| `TELESRV_CALL_MAX_ACTIVE_PER_USER` | int / `4` | Maximum non-terminal private calls per user. Non-positive values are normalized by the phone service. | +| `TELESRV_CALL_SIGNALING_MAX_BYTES` | int bytes / `65536` | Maximum payload for one `phone.sendSignalingData`. | +| `TELESRV_CALL_SIGNALING_RATE` | int / `50` | Signaling forwards per call per second; excess is silently dropped. | +| `TELESRV_CALL_EXPIRY_INTERVAL` | duration / `1s` | Call-expiry dispatcher polling interval. | +| `TELESRV_GROUPCALL_CHECK_TTL` | duration / `45s` | Participant liveness watermark expiry. Clients and the SFU reporter refresh it. | +| `TELESRV_GROUPCALL_SWEEP_INTERVAL` | duration / `10s` | Ghost-participant sweep interval. | +| `TELESRV_GROUPCALL_MAX_PARTICIPANTS` | int / `32` | Per-room participant cap for the current small-scale implementation. | +| `TELESRV_TURN_ENABLE` | bool / `true` | Enables embedded TURN/STUN relay data in private calls. False falls back to LAN/P2P-only behavior. | +| `TELESRV_TURN_UDP_PORT` | int / `12400` | Embedded TURN/STUN UDP listen port; must differ from the SFU port and be allowed through the firewall. | +| `TELESRV_TURN_ADVERTISE_IP` | string / empty | Client-reachable relay address. Empty falls back to SFU advertise IP, then general advertise IP. | +| `TELESRV_TURN_SECRET` | secret string / empty | HMAC secret for TURN REST credentials. Empty creates a process-random secret; multi-instance/external coturn deployments must configure one stable shared secret. | +| `TELESRV_TURN_RELAY_MIN_PORT` | int / `12500` | Inclusive relay allocation port minimum. | +| `TELESRV_TURN_RELAY_MAX_PORT` | int / `12999` | Inclusive relay allocation port maximum; must not be below the minimum. Open the whole range in the firewall. | +| `TELESRV_CALL_TURN_CREDENTIAL_TTL` | duration / `6h` | Per-call TURN credential lifetime. | +| `TELESRV_CALL_FORCE_RELAY` | bool / `false` | Forces `p2p_allowed=false` to test TURN relay paths. | +| `TELESRV_SFU_ENABLE` | bool / `true` | Enables embedded group-call media forwarding. False leaves signaling-only M0 behavior. | +| `TELESRV_SFU_UDP_PORT` | int / `12399` | Pion ICE UDPMux port; allow it through the firewall. | +| `TELESRV_SFU_ADVERTISE_IP` | string / empty | Client-reachable ICE candidate IP. Empty falls back to `TELESRV_ADVERTISE_IP`; loopback silently breaks real-device media. | +| `TELESRV_LIVESTREAM_ENABLE` | bool / `true` | Enables embedded RTMP ingest plus ffmpeg segmentation for channel livestreams. | +| `TELESRV_LIVESTREAM_RTMP_ADDR` | address / `:2400` | RTMP ingest TCP listen address. | +| `TELESRV_LIVESTREAM_RTMP_URL` | URL string / empty | OBS-facing server URL. Empty derives `rtmp://:2400/live`. | +| `TELESRV_LIVESTREAM_FFMPEG_PATH` | path/command / `ffmpeg` | ffmpeg executable path; the default resolves through `PATH`. | +| `TELESRV_LIVESTREAM_WORK_DIR` | path / empty | Segment working directory. Empty uses the system temporary directory. | +| `TELESRV_LIVESTREAM_SEGMENT_KEEP` | int seconds / `32` | Per-stream segment duration/window retained in memory; non-positive values are normalized by the livestream service. | + +## 12. Production minimum checklist + +At minimum, production operators should explicitly review and override the development credentials/endpoints: PostgreSQL DSN and TLS, Redis password/network exposure, RSA key persistence, fixed development auth code exposure, Admin credentials/session key, OTP Webhook/SMTP secrets, AI/Mapbox API keys, TURN secret and firewall ports, public URLs/scheme alignment, and non-loopback SFU/TURN advertise addresses for real devices. diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md new file mode 100644 index 00000000..e08a2291 --- /dev/null +++ b/docs/configuration.zh-CN.md @@ -0,0 +1,244 @@ +# telesrv 配置参数手册 + +英文版:[configuration.en.md](configuration.en.md) + +本文覆盖 `internal/config` 实际读取的全部配置。默认值和校验行为以 `internal/config/config.go` 为权威来源。所有配置修改都需要重启进程;telesrv 当前不支持配置热加载。 + +## 1. 加载方式、语法与优先级 + +- `TELESRV_CONFIG` 是选择 env 风格配置文件的**进程环境变量**。默认读取进程工作目录下的 `.env`;显式设为空可关闭文件加载。把它写在配置文件内部不会改变已选定的文件。 +- 优先级为:非空进程环境变量 → 非空文件值 → 代码默认值。四个可空监听项 `TELESRV_DEBUG_ADDR`、`TELESRV_BOT_API_ADDR`、`TELESRV_ADMIN_API_ADDR`、`TELESRV_PUBLIC_LINK_WEB_ADDR` 允许用显式空的进程环境变量覆盖文件中的非空值,从而关闭监听。 +- 文件支持空行、整行 `#` 注释、可选的 `export ` 前缀和 `KEY=VALUE`;支持单引号、双引号。行尾 `#` 不会被当作内联注释剥离。 +- 文件中的键必须以 `TELESRV_` 开头,且只能包含大写 ASCII 字母、数字和下划线。语法合法但当前二进制未知的 `TELESRV_*` 键会被接受但忽略。 +- bool 接受 `1/true/TRUE/True/yes/on` 和 `0/false/FALSE/False/no/off`;列表使用逗号分隔;时长使用 Go 格式,例如 `200ms`、`30s`、`5m`、`168h`。 +- int、float、bool、duration 的非法文本会回退代码默认值;URL、app scheme、app name 以及登录邮箱依赖关系校验失败会阻止启动。 +- 不要提交真实密码、token、私有 DSN 或 TURN secret。生产环境应使用受保护的 service environment 或密钥管理系统。 + +## 2. MTProto 监听、传输与资源预算 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_LISTEN` | string / `0.0.0.0:2398` | MTProto TCP 监听地址,必须与 patched 客户端可达地址/端口一致。 | +| `TELESRV_ADVERTISE_IP` | string / `127.0.0.1` | 媒体、通话等回退路径使用的客户端可达 IP;当前 TDesktop 静态 DC patch 不从这里获取 MTProto 地址。 | +| `TELESRV_RSA_KEY` | path / `data/server_rsa.pem` | MTProto RSA 私钥;缺失时自动生成。属于敏感文件,重启和升级间必须稳定保存。 | +| `TELESRV_DC` | int / `2` | 服务端 DC ID,必须与客户端 patch 及媒体/DC 元数据一致。 | +| `TELESRV_WEBSOCKET_ENABLE` | bool / `true` | 在 MTProto 监听端口启用 MTProto-over-WebSocket 分流。 | +| `TELESRV_WEBSOCKET_ALLOWED_ORIGINS` | list / `http://localhost:1234,http://127.0.0.1:1234` | 浏览器 WebSocket origin 白名单;`*` 只用于临时调试。 | +| `TELESRV_MTPROTO_MAX_CONNECTIONS` | int / `200000` | 全局物理连接 admission 上限;负数关闭该门禁。 | +| `TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP` | int / `4096` | 单来源 IP 物理连接上限;负数关闭该门禁。 | +| `TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES` | int / `256` | 高成本 RSA/DH 握手并发上限;负数关闭该门禁。 | +| `TELESRV_MTPROTO_RPC_MAX_INFLIGHT` | int / `32` | 单连接同时执行的 RPC 上限;非正值由 edge 归一为安全默认值。 | +| `TELESRV_MTPROTO_RPC_QUEUE_SIZE` | int / `64` | 单连接 RPC 排队容量;非正值使用 edge 默认值。 | +| `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | 调度后 RPC handler 的端到端超时。 | +| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | 共享公平调度器 worker 数。 | +| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | 进程级排队与执行中的 RPC task 上限。 | +| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 charge bytes / `536870912` | 进程级已预留/排队/执行中 RPC 内存 charge 预算;legacy 等于 copied body,exact 是 typed decode 前按 wire 与生成对象放大计算的保守 materialization charge,不代表可并发接收同等大小的 wire body。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES` | int / `262144` | 331 秒进程内重放窗口中,pending owner、completed `rpc_result` 与容量 tombstone 的全局 ownership 条目上限。owner 执行前先占 1 条,转 completed 时不重复计数。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES` | int64 bytes / `67108864` | 上述 ownership 的全局 retained-byte 上限;owner 先占 1 byte,Put 转移为真实 body 或 1-byte identity tombstone。不得低于 `16775168`(单条合法 outbound body 上限)。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES` | int / `32768` | 单 raw auth key 的 ownership 条目上限;与全局、session 层同时计费,防一个 auth key 吃满进程缓存。必须 `global >= auth >= session`。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES` | int64 bytes / `33554432` | 单 raw auth key retained-byte 上限;必须不低于单条合法 outbound body,且满足 byte 层级关系。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES` | int / `16384` | 单 `raw auth key + session_id` ownership 条目上限;不同 session 不共享该局部额度。 | +| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES` | int64 bytes / `16777216` | 单 `raw auth key + session_id` retained-byte 上限;默认略高于单条合法 outbound body,确保空预算时任一合法结果可完整进入。 | +| `TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH` | int / `2048` | 单 raw auth key 的 active pending owner 附加上限;必须不大于 `RPC_GLOBAL_MAX_TASKS` 和 auth entry 上限。Put/Abort 都立即归还此 active 额度。 | +| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | transport wire 与最大解密明文的进程级在途预算,在分配 payload 前预留。 | +| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | 单连接普通 outbound mailbox 容量。 | +| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | 单连接控制消息 mailbox 容量。 | +| `TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | resend pending message body 的全局预算。 | +| `TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | 并发加密 wire/codec/obfuscation scratch 的全局预算。 | + +## 3. HTTP 端点、公开链接与管理后台 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug 监听;空值关闭。生产必须保持 loopback,通过 SSH 隧道抓取。 | +| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。 | +| `TELESRV_ADMIN_API_ADDR` | nullable address / 空 | 进程内 Admin 写 API;空值关闭,生产应只监听 loopback。 | +| `TELESRV_ADMIN_API_TOKEN` | secret string / 空 | Admin API bearer token;启用 Admin API 时必须显式配置,并与 Admin UI 使用的 token 一致。 | +| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | 独立 `cmd/telesrv-admin` 监听地址。 | +| `TELESRV_ADMIN_UI_PASSWORD` | secret string / 空 | Admin UI 登录密码;它与 `TELESRV_ADMIN_UI_TOKEN` 至少配置一个。 | +| `TELESRV_ADMIN_UI_TOKEN` | secret string / 空 | Admin UI 替代登录凭证;管理写调用仍使用独立的 `TELESRV_ADMIN_API_TOKEN`。 | +| `TELESRV_ADMIN_SESSION_KEY` | secret string / 空 | 加密/签名 Admin UI session cookie;生产至少使用 32 字节随机值,修改会使已有会话失效。 | +| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | 客户端可见的公开链接根地址;允许 path,禁止 credentials、query、fragment。本地例:`http://127.0.0.1:2401`。 | +| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme,必须与 patched 客户端注册值一致;禁止 `tg`、`http`、`https`。 | +| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 | +| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名;trim 后非空、无控制字符、最多 64 个 Unicode 字符。 | +| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 | + +## 4. PostgreSQL、Redis、文件与 seed + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_POSTGRES_DSN` | secret DSN / `postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable` | 主业务持久库;生产必须替换开发凭证与 TLS 策略。 | +| `TELESRV_POSTGRES_MAX_CONNS` | int / `50` | pgxpool 最大连接数;`<=0` 使用 pgx 默认值,该默认通常不足以覆盖生产 outbox/RPC 并发。 | +| `TELESRV_POSTGRES_MIN_CONNS` | int / `16` | pgxpool 预热最小连接数。 | +| `TELESRV_REDIS_ADDR` | address / `127.0.0.1:6399` | 验证码、限流、共享更新/缓存易失态使用的 Redis。 | +| `TELESRV_REDIS_PASSWORD` | secret string / 空 | Redis 密码。 | +| `TELESRV_REDIS_DB` | int / `0` | Redis 逻辑库编号。 | +| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` 语言包 seed 目录。 | +| `TELESRV_OFFICIAL_GIFTS_DIR` | path / `data/official-gifts` | `cmd/giftfetch` 生成的只读官方礼物快照;供管理后台选择、验哈希并显式导入。 | +| `TELESRV_BLOB_DIR` | path / `data/blobs` | 本地开发 blob backend 的媒体字节根目录。 | +| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 | +| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 | + +语言包 seed 以文件 manifest 为事实源。新增语言时放入 `data/langpack//__v.strings` 并重启 `telesrv`;`pack` 必须与所在一级目录一致,允许 Telegram 已使用的字母、数字、`-` 与 `_`(例如 `android_x`),`lang` 会统一为小写、连字符形式(例如 `pt_BR` 归一为 `pt-br`)。同一语言存在多个文件时只读取最高版本。修改已有语言的有效内容必须提高版本;同版本有效内容变化或版本倒退会阻止启动。删除语言文件或整个 pack 子目录后,下次重启会原子移除对应数据库目录和字符串。启动先流式计算源文件 SHA-256;未变化文件复用上次原子 manifest,不解析字符串也不写库,只有新增或变化文件才解析并通过 PostgreSQL `COPY` 整包替换。 + +## 5. 登录、OTP Provider、SMTP 与 passkey + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | `PHONE_CODE_DELIVERY_PROVIDER=development` 使用的固定开发登录码;不得把默认值暴露在公网环境。 | +| `TELESRV_AUTH_CODE_TTL` | duration / `5m` | 登录/注册/邮箱验证码有效期,必须为正数。 | +| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | 单 code/hash 最大错误次数,必须为正数。 | +| `TELESRV_PHONE_CODE_LENGTH` | int / `5` | `webhook` phone provider 生成的随机 SMS 验证码长度,允许 `4..10`。 | +| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | 每个规范化手机号摘要在窗口内的发码上限;`<=0` 关闭该维度。 | +| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | 每个 raw auth key 在窗口内的发码上限;`<=0` 关闭该维度。 | +| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 | +| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息,Webhook 只是附加渠道。 | +| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp` 或 `webhook`。已有账号的登录邮箱码会先同码镜像到 777000;邮箱 setup/change 仍只走 provider。 | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。只允许 `http`/`https` 且不得含 userinfo。 | +| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / 空 | 可选 HMAC-SHA256 签名密钥;非空时发送 `X-Telesrv-Signature`。 | +| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP 请求超时,启用 Webhook 时必须为正数。 | +| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码;email provider 为 `smtp` 时要求 SMTP 配置,`webhook` 时不依赖 SMTP。 | +| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | 强制没有登录邮箱的账号设置邮箱;要求 `TELESRV_LOGIN_EMAIL_ENABLE=true`。 | +| `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | 邮箱验证码长度,允许 `4..10`。 | +| `TELESRV_SMTP_HOST` | string / 空 | SMTP host;启用登录邮箱且 email provider 为 `smtp` 时必填。 | +| `TELESRV_SMTP_PORT` | int / `587` | SMTP 端口;使用 SMTP provider 时必须为 `1..65535`。 | +| `TELESRV_SMTP_USERNAME` | sensitive string / 空 | SMTP 用户名;`TELESRV_SMTP_FROM` 为空时也用作发件人。 | +| `TELESRV_SMTP_PASSWORD` | secret string / 空 | SMTP 密码。 | +| `TELESRV_SMTP_FROM` | email/string / 空 | envelope/header 发件人;启用登录邮箱时它与 SMTP username 至少一个非空。 | +| `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | 登录邮件展示的发件人名称。 | +| `TELESRV_SMTP_TLS` | enum / `starttls` | 仅允许 `starttls`、`tls`、`none`,其它值阻止启动。 | +| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP 操作超时;使用 SMTP provider 时必须为正数。 | +| `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID,用于校验 `rpIdHash`;Android Credential Manager 必须与公网 `assetlinks.json` 对齐。 | +| `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / 空 | WebAuthn origin 白名单;空值不做显式 origin 校验,因为服务端可能无法预知 Android APK-key-hash origin。 | + +## 6. 地图、外链媒体、链接预览与上传 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_MAPBOX_TOKEN` | secret string / 空 | `upload.getWebFile` 地图缩略图使用的 Mapbox Static Images token;空值使用确定性占位图。 | +| `TELESRV_MAPTILE_CACHE_DIR` | path / `data/maptiles` | 地图缩略图磁盘缓存,保证分片下载字节稳定并控制上游配额。 | +| `TELESRV_EXTERNAL_MEDIA_ENABLE` | bool / `true` | 启用带 SSRF 防护的外链 photo/document 抓取。 | +| `TELESRV_EXTERNAL_MEDIA_MAX_BYTES` | int bytes / `10485760` | 单次外链媒体响应体上限;下游把 `<=0` 归一为 10 MiB 安全默认值。 | +| `TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN` | int / `60` | 全局每分钟外链媒体抓取数;下游把 `<=0` 归一为默认值。 | +| `TELESRV_WEBPAGE_PREVIEW_ENABLE` | bool / `true` | 启用带 SSRF 防护的网页元数据/图片抓取和链接预览。 | +| `TELESRV_WEBPAGE_PREVIEW_MAX_BYTES` | int bytes / `5242880` | 预览 HTML 与图片抓取共用的响应体上限;下游把 `<=0` 归一为 5 MiB。 | +| `TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN` | int / `300` | 全局每分钟预览上游请求数;一次解析最多产生两次请求。 | +| `TELESRV_UPLOAD_PART_TTL` | duration / `24h` | 未组装上传分片保留期。 | +| `TELESRV_UPLOAD_PART_GC_INTERVAL` | duration / `30m` | upload part GC 轮询间隔。 | +| `TELESRV_UPLOAD_PART_GC_BATCH` | int / `10000` | 单批 upload part GC 最大删除行数。 | +| `TELESRV_UPLOAD_INFLIGHT_MAX_BYTES` | int64 bytes / `4194304000` | 单用户未组装上传字节上限;`<=0` 表示不限。 | +| `TELESRV_UPLOAD_INFLIGHT_MAX_PARTS` | int / `8000` | 单用户未组装分片行数上限;`<=0` 表示不限。 | +| `TELESRV_UPLOAD_INFLIGHT_MAX_FILES` | int / `64` | 单用户并发未组装 `file_id` 上限;`<=0` 表示不限。 | + +## 7. AI compose 与 Business automation + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business 自动回复生成器。可填 `echo`/空值(回显触发文本)、`template`/`quick_reply`/`quick-reply`(使用 quick reply 模板),或 `ai`/`compose_ai`/`ai_compose`/`aicompose`/`kimi`(复用 `TELESRV_AI_PROVIDERS` provider 链)。这里不接受任意 provider 名;例如使用 Ollama 时填 `TELESRV_BUSINESS_AI_PROVIDER=ai`,实际 provider 由 `TELESRV_AI_PROVIDERS=ollama,local` 决定。 | +| `TELESRV_AI_ENABLED` | bool / `true` | 启用客户端输入框改写/润色;关闭时返回空 tone 集合并隐藏入口。 | +| `TELESRV_AI_PROVIDERS` | list / `local` | 按顺序尝试的 provider 链;空列表回退确定性 `local`,不访问外网。 | +| `TELESRV_AI_TIMEOUT` | duration / `15s` | 单次 provider 调用总超时。 | +| `TELESRV_AI_RATE_LIMIT` | int / `20` | 单账号每窗口 compose 次数。 | +| `TELESRV_AI_RATE_WINDOW` | duration / `1m` | compose AI 限流窗口。 | +| `TELESRV_AI_LOG_CONTENT` | bool / `false` | false 时日志只写长度/provider/状态;开启可能暴露用户输入和生成文本。 | +| `TELESRV_TRANSLATION_ENABLED` | bool / `true` | 启用 `messages.translateText`;仍需至少一个远程 AI provider,local 回显 provider 不会被用作翻译。 | +| `TELESRV_TRANSLATION_PROVIDERS` | list / 空 | 从 `TELESRV_AI_PROVIDERS` 选择用于翻译的 provider 名;空表示使用其中全部远程 provider。 | +| `TELESRV_TRANSLATION_TIMEOUT` | duration / `15s` | 一批翻译的总超时;批内最多 20 条、provider 并发固定为 4。 | +| `TELESRV_TRANSLATION_RATE_LIMIT` | int / `60` | 单账号每窗口允许的翻译文本条数;一批 20 条计 20,防止批量请求放大 provider 调用。 | +| `TELESRV_TRANSLATION_RATE_WINDOW` | duration / `1m` | 翻译限流窗口。 | + +聊天翻译会把用户主动选择翻译的消息正文发送给所配置的外部 provider。默认日志不记录正文,但部署者仍应在隐私政策中披露上游处理方;只配置 `local` 时服务端返回 `TRANSLATIONS_DISABLED`,不会回原文冒充译文。 + +对 `TELESRV_AI_PROVIDERS` 中的每个名称,telesrv 会转大写并把非字母数字字符替换为 `_`,再读取下列动态参数。例如 `openai-compatible` 对应 suffix `OPENAI_COMPATIBLE`。 + +| 动态参数 | 类型 / 默认值 | 说明 | +|---|---|---| +| `TELESRV_AI__KIND` | string / 由名称推导 | adapter 类型。内置值包括 `local`、`openai_responses`、`openai_chat`、`gemini`、`anthropic`;常用名称会自动映射。 | +| `TELESRV_AI__BASE_URL` | URL string / 空 | provider endpoint 覆盖;兼容接口或自托管 provider 通常需要。 | +| `TELESRV_AI__API_KEY` | secret string / provider fallback | provider 凭证;已知 provider 可回退到下述进程环境变量。 | +| `TELESRV_AI__MODEL` | string / 空 | provider model id;外部 provider 通常必填。 | +| `TELESRV_AI__MAX_OUTPUT_TOKENS` | int / `1024` | 请求的输出 token 上限。 | +| `TELESRV_AI__TEMPERATURE` | float / `0.2` | 采样 temperature。 | +| `TELESRV_AI__OMIT_TEMPERATURE` | bool / `false` | 对拒绝 temperature 字段的模型/provider 不发送该字段。 | +| `TELESRV_AI__THINKING` | string / 空 | provider 特定 reasoning/thinking 模式,统一转小写,例如 `disabled`。 | + +下列 fallback 只支持**进程环境变量**,因为 env 文件会拒绝不以 `TELESRV_` 开头的键:`OPENAI_API_KEY`、`GEMINI_API_KEY`、`ANTHROPIC_API_KEY`。显式 `TELESRV_AI__API_KEY` 优先级更高。 + +## 8. Read-model 与 auth-key 缓存 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES` | int / `262144` | Router temp→perm auth-key binding 缓存容量。 | +| `TELESRV_TEMP_KEY_CACHE_TTL` | duration / `30m` | 复核周期;正常写入由 bind/revoke 精确失效,TTL 兜底跨进程/异常路径。 | +| `TELESRV_CHANNEL_ROW_CACHE_MAX` | int / `50000` | 共享 channel row 缓存容量;`<=0` 同时关闭缓存及 LISTEN/NOTIFY listener。 | +| `TELESRV_CHANNEL_MEMBER_CACHE_MAX` | int / `100000` | channel member/access read-model 缓存容量;`<=0` 关闭。 | +| `TELESRV_CHANNEL_DIALOG_CACHE_MAX` | int / `100000` | viewer/channel dialog 投影缓存容量;`<=0` 关闭。 | +| `TELESRV_CHANNEL_BOOST_CACHE_MAX` | int / `100000` | channel boost read-model 缓存容量;`<=0` 关闭。 | +| `TELESRV_CHANNEL_BOOST_CACHE_TTL` | duration / `10s` | boost 失效通知遗漏时允许的最大陈旧窗口。 | + +## 9. Outbox、推送、限流、retention 与 GC + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_OUTBOX_WORKERS` | int / `4` | 并发 outbox worker 数;稳定逻辑分片保持单用户 pts 顺序。 | +| `TELESRV_OUTBOX_BATCH` | int / `100` | 每次 poll 最大 claim 行数;增大提高吞吐,也增加 DB/推送突发。 | +| `TELESRV_OUTBOX_INTERVAL` | duration / `200ms` | 两次 outbox claim 之间的等待。 | +| `TELESRV_OUTBOX_LEASE_TIMEOUT` | duration / `30s` | `dispatching` 行可被重新 claim 的超时;必须大于最坏单批投递耗时。 | +| `TELESRV_OUTBOX_POISON_RETENTION` | duration / `1m` | terminal failed 投递头的排障保留窗口;durable update 仍可经 difference 恢复。 | +| `TELESRV_OUTBOX_POISON_CLEANUP_INTERVAL` | duration / `15s` | terminal failed head 清理周期,独立于大表 retention。 | +| `TELESRV_OUTBOUND_PUSH_TIMEOUT` | duration / `200ms` | best-effort 在线 update 入队最长等待。 | +| `TELESRV_SEND_RATE_LIMIT` | int / `30` | 单账号每发送窗口允许的消息数;`<=0` 关闭。 | +| `TELESRV_SEND_RATE_WINDOW` | duration / `1m` | 发送限流窗口。 | +| `TELESRV_CATCHUP_RATE_LIMIT` | int / `0` | 单用户每窗口 difference/catch-up RPC 数;`<=0` 关闭。 | +| `TELESRV_CATCHUP_RATE_WINDOW` | duration / `1m` | catch-up 限流窗口。 | +| `TELESRV_CHANNEL_NUDGE_MAX_TARGETS` | int / `0` | 单次 channel fan-out nudge 目标上限;`<=0` 使用内置默认值。 | +| `TELESRV_UPDATE_EVENT_RETENTION` | duration / `168h` | durable update log 保留期;只删除已被协议安全水位/状态覆盖的事件。 | +| `TELESRV_BOT_API_UPDATE_RETENTION` | duration / `24h` | Bot API update 队列最长保留期;已确认行另有固定短宽限。 | +| `TELESRV_ORPHAN_AUTH_KEY_RETENTION` | duration / `24h` | 没有 authorization/temp binding/活跃连接的握手 auth key 最短保留期。 | +| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | 通用 retention worker 周期。 | +| `TELESRV_RETENTION_BATCH` | int / `10000` | 单次通用 retention 最大删除行数。 | + +## 10. Premium 与 Stars 开发赠送 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_PREMIUM_GRANT_MONTHS` | int / `3` | 新注册账号默认 Premium 月数;`0` 关闭新赠送,不影响已有迁移 backfill。 | +| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | 对所有账号幂等惰性授予的 Stars 起始余额;`0` 关闭自动赠送。 | +| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | 过期 Premium 清理/推送周期;读取路径独立即时派生到期状态。 | +| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | 单次 sweep 最大处理行数。 | + +## 11. 私聊通话、群通话、TURN、SFU 与直播 + +| 参数 | 类型 / 代码默认值 | 说明与约束 | +|---|---|---| +| `TELESRV_CALL_RING_TIMEOUT` | duration / `90s` | 私聊通话 ringing/accepted 服务端兜底超时,应与客户端 `callRingTimeoutMs` 保持一致。 | +| `TELESRV_CALL_TOMBSTONE_TTL` | duration / `60s` | 终态通话 tombstone 的幂等/晚到 RPC 吸收窗口。 | +| `TELESRV_CALL_MAX_ACTIVE_PER_USER` | int / `4` | 单用户非终态私聊通话上限;非正值由 phone service 归一。 | +| `TELESRV_CALL_SIGNALING_MAX_BYTES` | int bytes / `65536` | 单条 `phone.sendSignalingData` 载荷上限。 | +| `TELESRV_CALL_SIGNALING_RATE` | int / `50` | 单通话每秒信令转发上限,超限静默丢弃。 | +| `TELESRV_CALL_EXPIRY_INTERVAL` | duration / `1s` | 通话 expiry dispatcher 轮询间隔。 | +| `TELESRV_GROUPCALL_CHECK_TTL` | duration / `45s` | 群通话参与者 liveness 水位过期阈值,客户端与 SFU reporter 都会刷新。 | +| `TELESRV_GROUPCALL_SWEEP_INTERVAL` | duration / `10s` | 幽灵参与者 sweep 周期。 | +| `TELESRV_GROUPCALL_MAX_PARTICIPANTS` | int / `32` | 当前小规模实现的单房间参与者上限。 | +| `TELESRV_TURN_ENABLE` | bool / `true` | 启用内嵌 TURN/STUN 与私聊通话 relay 下发;false 回退 LAN/P2P-only。 | +| `TELESRV_TURN_UDP_PORT` | int / `12400` | 内嵌 TURN/STUN UDP 监听端口;必须与 SFU 端口不同并放行防火墙。 | +| `TELESRV_TURN_ADVERTISE_IP` | string / 空 | 客户端可达 relay IP;空值依次回退 SFU advertise IP、通用 advertise IP。 | +| `TELESRV_TURN_SECRET` | secret string / 空 | TURN REST credential HMAC secret;空值生成进程级随机值,多实例/外部 coturn 必须显式共享稳定值。 | +| `TELESRV_TURN_RELAY_MIN_PORT` | int / `12500` | relay 分配端口范围下界(含)。 | +| `TELESRV_TURN_RELAY_MAX_PORT` | int / `12999` | relay 分配端口范围上界(含),不得小于下界,防火墙需放行整个范围。 | +| `TELESRV_CALL_TURN_CREDENTIAL_TTL` | duration / `6h` | 按通话签发的 TURN credential 有效期。 | +| `TELESRV_CALL_FORCE_RELAY` | bool / `false` | 强制 `p2p_allowed=false`,用于验证 TURN relay 路径。 | +| `TELESRV_SFU_ENABLE` | bool / `true` | 启用内嵌群通话媒体转发;false 保留仅信令 M0 模式。 | +| `TELESRV_SFU_UDP_PORT` | int / `12399` | Pion ICE UDPMux 端口,必须放行防火墙。 | +| `TELESRV_SFU_ADVERTISE_IP` | string / 空 | 下发给客户端的 ICE candidate IP;空值回退 `TELESRV_ADVERTISE_IP`,loopback 会静默破坏真机媒体。 | +| `TELESRV_LIVESTREAM_ENABLE` | bool / `true` | 启用频道 RTMP ingest 与 ffmpeg 切段。 | +| `TELESRV_LIVESTREAM_RTMP_ADDR` | address / `:2400` | RTMP ingest TCP 监听地址。 | +| `TELESRV_LIVESTREAM_RTMP_URL` | URL string / 空 | 返回 OBS 的服务器地址;空值派生 `rtmp://:2400/live`。 | +| `TELESRV_LIVESTREAM_FFMPEG_PATH` | path/command / `ffmpeg` | ffmpeg 可执行文件路径,默认从 `PATH` 解析。 | +| `TELESRV_LIVESTREAM_WORK_DIR` | path / 空 | segment 临时工作目录;空值使用系统临时目录。 | +| `TELESRV_LIVESTREAM_SEGMENT_KEEP` | int seconds / `32` | 每路直播在内存保留的 segment 秒数/窗口;非正值由 livestream service 归一。 | + +## 12. 生产部署最低检查清单 + +生产至少应显式检查并替换这些开发值:PostgreSQL DSN 与 TLS、Redis 密码和网络暴露、RSA 私钥持久化、固定开发验证码暴露、Admin 凭证/session key、OTP Webhook/SMTP secret、AI/Mapbox API key、TURN secret 与防火墙端口、公开 URL/scheme 与客户端一致性,以及真机所需的非 loopback SFU/TURN advertise IP。 diff --git a/go.mod b/go.mod index 5cfbe945..8dae72dc 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/gotd/ige v0.2.2 github.com/gotd/log/logzap v0.1.1 - github.com/iamxvbaba/td v1.1.0 + github.com/iamxvbaba/td v1.1.3 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.9.2 github.com/pion/datachannel v1.6.2 diff --git a/go.sum b/go.sum index 1be70761..eeed5c28 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/iamxvbaba/td v1.1.0 h1:6Ddxi8sOuxOioGs3vyDGWlC6q53j4AQ2hFJ4AirJvFk= -github.com/iamxvbaba/td v1.1.0/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04= +github.com/iamxvbaba/td v1.1.3 h1:g9GE2jZVB1U1N8HuaRy707EzYfcsEuXIC//wI23VYsg= +github.com/iamxvbaba/td v1.1.3/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/admin/official_gift_snapshot_test.go b/internal/admin/official_gift_snapshot_test.go new file mode 100644 index 00000000..7899fd14 --- /dev/null +++ b/internal/admin/official_gift_snapshot_test.go @@ -0,0 +1,83 @@ +package admin + +import ( + "context" + "os" + "testing" + + stargiftapp "telesrv/internal/app/stargifts" + "telesrv/internal/domain" + "telesrv/internal/officialgifts" + "telesrv/internal/store/memory" +) + +// This opt-in regression uses the exact official snapshot reported by the +// Party Sparkler import issue. It crosses official catalog verification, +// admin mapping, animation materialization and the complete store validator. +func TestConfiguredOfficialPartySparklerImport(t *testing.T) { + root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR") + if root == "" { + t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set") + } + ctx := context.Background() + giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2) + svc := NewService(Dependencies{ + Commands: newMemoryCommandRepo(), + Gifts: giftService, + OfficialGifts: officialgifts.New(root), + Now: fixedNow, + }) + result, err := svc.ImportOfficialStarGift(ctx, ImportOfficialStarGiftRequest{ + CommandMeta: CommandMeta{ + CommandID: "exec-party-sparkler-snapshot-regression", + Actor: "test", + Reason: "verify official collectible import", + }, + SourceGiftID: "6003643167683903930", + Enabled: true, + IncludeCollectible: true, + }) + if err != nil { + t.Fatalf("import Party Sparkler: result=%+v err=%v", result, err) + } + if result.Details["models"] != 100 || result.Details["patterns"] != 136 || result.Details["backdrops"] != 60 { + t.Fatalf("Party Sparkler details = %+v", result.Details) + } + catalog, err := giftService.Catalog(ctx) + if err != nil || len(catalog) != 1 { + t.Fatalf("catalog=%+v err=%v, want one gift", catalog, err) + } + preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID) + if err != nil || !ok || len(preview.Models) != 100 || len(preview.Patterns) != 136 || len(preview.Backdrops) != 60 { + t.Fatalf("preview counts=%d/%d/%d ok=%v err=%v", len(preview.Models), len(preview.Patterns), len(preview.Backdrops), ok, err) + } + for _, model := range preview.Models { + if model.Document == nil || !model.Document.IsSticker() || model.Document.IsCustomEmoji() { + t.Fatalf("model %q document=%+v, want ordinary sticker", model.Name, model.Document) + } + } + for _, pattern := range preview.Patterns { + if pattern.Document == nil || pattern.Document.IsSticker() || !pattern.Document.IsCustomEmoji() || + !hasTextColorCustomEmoji(pattern.Document.Attributes) || !hasInlinePathThumb(pattern.Document.Thumbs) { + t.Fatalf("pattern %q document=%+v, want text-color custom emoji with inline path", pattern.Name, pattern.Document) + } + } +} + +func hasTextColorCustomEmoji(attributes []domain.DocumentAttribute) bool { + for _, attribute := range attributes { + if attribute.Kind == domain.DocAttrCustomEmoji && attribute.TextColor { + return true + } + } + return false +} + +func hasInlinePathThumb(thumbs []domain.PhotoSize) bool { + for _, thumb := range thumbs { + if thumb.Kind == domain.PhotoSizeKindPath && thumb.Type != "" && len(thumb.Bytes) > 0 { + return true + } + } + return false +} diff --git a/internal/admin/service.go b/internal/admin/service.go index 8ee6b89f..386f1591 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -4,15 +4,18 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "net/url" "reflect" "sort" + "strconv" "strings" "time" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) const ( @@ -25,6 +28,7 @@ const ( ActionDeletePrivateMessages = "messages.delete_private_messages" ActionDeletePrivateHistory = "messages.delete_private_history" ActionImportStarGift = "gifts.import" + ActionImportOfficialStarGift = "gifts.official.import" ActionPublishGiftCollectibles = "gifts.collectibles.publish" ActionSetStarGiftEnabled = "gifts.set_enabled" ActionSetStarGiftSortOrder = "gifts.set_sort_order" @@ -94,7 +98,9 @@ type MessagesService interface { type GiftsService interface { PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) + PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) + CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) @@ -103,6 +109,11 @@ type GiftsService interface { CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) } +type OfficialGiftsSource interface { + List(ctx context.Context) ([]officialgifts.GiftSummary, error) + Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error) +} + type Dependencies struct { Commands CommandRepository Restrictions RestrictionStore @@ -116,6 +127,7 @@ type Dependencies struct { ChannelNotifier ChannelNotifier Messages MessagesService Gifts GiftsService + OfficialGifts OfficialGiftsSource Now func() time.Time } @@ -132,6 +144,7 @@ type Service struct { channelNotifier ChannelNotifier messages MessagesService gifts GiftsService + officialGifts OfficialGiftsSource now func() time.Time } @@ -177,6 +190,9 @@ func (s *Service) Configure(deps Dependencies) *Service { if deps.Gifts != nil { s.gifts = deps.Gifts } + if deps.OfficialGifts != nil { + s.officialGifts = deps.OfficialGifts + } if deps.Now != nil { s.now = deps.Now } @@ -219,6 +235,23 @@ type ImportStarGiftRequest struct { Data []byte `json:"-"` } +type ImportOfficialStarGiftRequest struct { + CommandMeta + SourceGiftID string `json:"source_gift_id"` + GiftID int64 `json:"gift_id,omitempty"` + Title string `json:"title"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + IncludeCollectible bool `json:"include_collectible"` + UpgradeStars int64 `json:"upgrade_stars,omitempty"` + SupplyTotal int `json:"supply_total,omitempty"` + SlugPrefix string `json:"slug_prefix,omitempty"` + ManifestSHA256 string `json:"manifest_sha256,omitempty"` + AssetSHA256 []string `json:"asset_sha256,omitempty"` +} + type SetStarGiftEnabledRequest struct { CommandMeta GiftID int64 `json:"gift_id"` @@ -796,8 +829,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) req.ContentSHA = hex.EncodeToString(animation.SHA256) return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) { details := map[string]any{ - "gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars, - "convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder, + "gift_id": strconv.FormatInt(req.GiftID, 10), "title": strings.TrimSpace(req.Title), + "stars": strconv.FormatInt(req.Stars, 10), "convert_stars": strconv.FormatInt(req.ConvertStars, 10), + "enabled": req.Enabled, "sort_order": req.SortOrder, "source_format": animation.SourceFormat, "source_name": animation.SourceName, "sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height, "frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON), @@ -813,13 +847,232 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) if err != nil { return CommandResult{Details: details}, err } - details["gift_id"] = entry.Gift.ID - details["revision_id"] = entry.Gift.RevisionID + details["gift_id"] = strconv.FormatInt(entry.Gift.ID, 10) + details["revision_id"] = strconv.FormatInt(entry.Gift.RevisionID, 10) details["revision"] = entry.Revision return CommandResult{Message: "star gift imported", Details: details}, nil }) } +func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) { + if s == nil || s.officialGifts == nil { + return nil, officialgifts.ErrUnavailable + } + return s.officialGifts.List(ctx) +} + +func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) { + if s == nil || s.officialGifts == nil || s.gifts == nil { + return nil, false, officialgifts.ErrUnavailable + } + id, err := strconv.ParseInt(strings.TrimSpace(sourceGiftID), 10, 64) + if err != nil || id <= 0 { + return nil, false, officialgifts.ErrNotFound + } + bundle, err := s.officialGifts.Bundle(ctx, id, false) + if errors.Is(err, officialgifts.ErrNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + animation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data) + if err != nil { + return nil, false, err + } + return animation.JSON, true, nil +} + +func (s *Service) ImportOfficialStarGift(ctx context.Context, req ImportOfficialStarGiftRequest) (CommandResult, error) { + if s == nil || s.gifts == nil || s.officialGifts == nil { + return CommandResult{}, fmt.Errorf("official star gift importer is not configured") + } + sourceID, err := strconv.ParseInt(strings.TrimSpace(req.SourceGiftID), 10, 64) + if err != nil || sourceID <= 0 || req.GiftID < 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 { + return CommandResult{}, domain.ErrStarGiftInvalid + } + bundle, err := s.officialGifts.Bundle(ctx, sourceID, req.IncludeCollectible) + if err != nil { + return CommandResult{}, err + } + if req.Title = strings.TrimSpace(req.Title); req.Title == "" { + req.Title = strings.TrimSpace(bundle.Gift.Title) + if req.Title == "" { + req.Title = "Official gift " + req.SourceGiftID + } + } + if req.Stars <= 0 { + req.Stars = bundle.Gift.Stars + } + if req.ConvertStars < 0 || req.ConvertStars > req.Stars || len([]rune(req.Title)) > domain.MaxStarGiftTitleRunes { + return CommandResult{}, domain.ErrStarGiftInvalid + } + if req.UpgradeStars <= 0 { + req.UpgradeStars = bundle.Gift.UpgradeStars + } + if req.SupplyTotal <= 0 { + req.SupplyTotal = bundle.Gift.AvailabilityTotal + } + if req.SlugPrefix = strings.ToLower(strings.TrimSpace(req.SlugPrefix)); req.SlugPrefix == "" { + req.SlugPrefix = "official-" + req.SourceGiftID + } + + baseAnimation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data) + if err != nil { + return CommandResult{}, fmt.Errorf("prepare official gift animation: %w", err) + } + assetHashes := []string{bundle.BaseDocument.SHA256} + rarityCounts := map[string]int{} + var background *domain.StarGiftBackground + if bundle.Gift.Background != nil { + background = &domain.StarGiftBackground{ + CenterColor: bundle.Gift.Background.CenterColor, + EdgeColor: bundle.Gift.Background.EdgeColor, + TextColor: bundle.Gift.Background.TextColor, + } + } + var collectible *domain.StarGiftCollectibleWrite + if req.IncludeCollectible { + if bundle.Collectible == nil { + return CommandResult{}, domain.ErrStarGiftCollectibleInvalid + } + models := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Models)) + for index, value := range bundle.Collectible.Models { + animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data) + if err != nil { + return CommandResult{}, fmt.Errorf("prepare official model %q: %w", value.Name, err) + } + rarityKind, permille, err := officialRarity(value.Rarity) + if err != nil { + return CommandResult{}, err + } + models = append(models, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleModel, + Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille, + Crafted: value.Crafted, OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation}) + assetHashes = append(assetHashes, value.Document.SHA256) + rarityCounts[string(rarityKind)]++ + } + patterns := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Patterns)) + for index, value := range bundle.Collectible.Patterns { + animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data) + if err != nil { + return CommandResult{}, fmt.Errorf("prepare official pattern %q: %w", value.Name, err) + } + rarityKind, permille, err := officialRarity(value.Rarity) + if err != nil { + return CommandResult{}, err + } + patterns = append(patterns, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectiblePattern, + Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille, + OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation}) + assetHashes = append(assetHashes, value.Document.SHA256) + rarityCounts[string(rarityKind)]++ + } + backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Backdrops)) + for index, value := range bundle.Collectible.Backdrops { + rarityKind, permille, err := officialRarity(value.Rarity) + if err != nil { + return CommandResult{}, err + } + backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop, + Name: strings.TrimSpace(value.Name), BackdropID: value.BackdropID, CenterColor: value.CenterColor, + EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor, + RarityKind: rarityKind, RarityPermille: permille, SortOrder: index}) + rarityCounts[string(rarityKind)]++ + } + collectible = &domain.StarGiftCollectibleWrite{GiftID: req.GiftID, UpgradeStars: req.UpgradeStars, + SupplyTotal: req.SupplyTotal, SlugPrefix: req.SlugPrefix, Models: models, Patterns: patterns, Backdrops: backdrops, + Actor: req.Actor, CommandID: req.CommandID, OfficialGiftID: sourceID, + SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...)} + validation := *collectible + if validation.GiftID == 0 { + validation.GiftID = 1 + } + if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil { + return CommandResult{}, err + } + } + req.ManifestSHA256 = hex.EncodeToString(bundle.ManifestSHA256) + sort.Strings(assetHashes) + req.AssetSHA256 = assetHashes + write := domain.StarGiftCatalogBundleWrite{Catalog: domain.StarGiftCatalogWrite{ + GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars, + Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: baseAnimation, Actor: req.Actor, CommandID: req.CommandID, + OfficialGiftID: sourceID, SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...), + OfficialSourceJSON: append([]byte(nil), bundle.SourceJSON...), + // The snapshot describes Telegram's global market, not this deployment's + // inventory. Keep the complete source JSON as provenance, while publishing + // regular official imports as a fresh, locally purchasable catalog entry. + // Local resale counters and sale dates are derived by lifecycle writes. + Limited: false, SoldOut: false, Birthday: bundle.Gift.Birthday, + RequirePremium: bundle.Gift.RequirePremium, LimitedPerUser: bundle.Gift.LimitedPerUser, + PeerColorAvailable: bundle.Gift.PeerColorAvailable, Auction: bundle.Gift.Auction, + AvailabilityRemains: 0, AvailabilityTotal: 0, + AvailabilityResale: 0, FirstSaleDate: 0, + LastSaleDate: 0, ResellMinStars: 0, + PerUserTotal: bundle.Gift.PerUserTotal, LockedUntilDate: bundle.Gift.LockedUntilDate, + AuctionSlug: bundle.Gift.AuctionSlug, GiftsPerRound: bundle.Gift.GiftsPerRound, + AuctionStartDate: bundle.Gift.AuctionStartDate, UpgradeVariants: bundle.Gift.UpgradeVariants, + Background: background, + }, Collectible: collectible} + return s.runCommand(ctx, req.CommandMeta, ActionImportOfficialStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) { + details := map[string]any{"source_gift_id": req.SourceGiftID, "gift_id": strconv.FormatInt(req.GiftID, 10), + "manifest_sha256": req.ManifestSHA256, "title": req.Title, "stars": strconv.FormatInt(req.Stars, 10), + "convert_stars": strconv.FormatInt(req.ConvertStars, 10), "include_collectible": req.IncludeCollectible, + "verified_asset_count": len(assetHashes), "rarity_counts": rarityCounts, + "official_limited": bundle.Gift.Limited, "official_sold_out": bundle.Gift.SoldOut, + "official_auction": bundle.Gift.Auction, "official_birthday": bundle.Gift.Birthday, + "official_require_premium": bundle.Gift.RequirePremium, + "official_availability_remains": bundle.Gift.AvailabilityRemains, + "official_availability_total": bundle.Gift.AvailabilityTotal, + "official_availability_resale": bundle.Gift.AvailabilityResale, + } + if bundle.Collectible != nil { + details["models"] = len(bundle.Collectible.Models) + details["patterns"] = len(bundle.Collectible.Patterns) + details["backdrops"] = len(bundle.Collectible.Backdrops) + crafted := 0 + for _, model := range bundle.Collectible.Models { + if model.Crafted { + crafted++ + } + } + details["crafted_models"] = crafted + } + if req.DryRun { + return CommandResult{Message: "official star gift bundle validated", Details: details}, nil + } + result, err := s.gifts.CreateCatalogBundle(ctx, write) + if err != nil { + return CommandResult{Details: details}, err + } + details["gift_id"] = strconv.FormatInt(result.Catalog.Gift.ID, 10) + details["catalog_revision_id"] = strconv.FormatInt(result.Catalog.Gift.RevisionID, 10) + if result.Collectible != nil { + details["collectible_revision_id"] = strconv.FormatInt(result.Collectible.ID, 10) + details["collectible_revision"] = result.Collectible.Revision + } + return CommandResult{Message: "official star gift bundle imported", Details: details}, nil + }) +} + +func officialRarity(value officialgifts.Rarity) (domain.StarGiftAttributeRarityKind, int, error) { + kind := domain.StarGiftAttributeRarityKind(strings.ToLower(strings.TrimSpace(value.Kind))) + if !kind.Valid() { + return "", 0, domain.ErrStarGiftCollectibleInvalid + } + if kind == domain.StarGiftRarityPermille { + if value.Permille == nil || *value.Permille <= 0 || *value.Permille > 1000 { + return "", 0, domain.ErrStarGiftCollectibleInvalid + } + return kind, *value.Permille, nil + } + if value.Permille != nil { + return "", 0, domain.ErrStarGiftCollectibleInvalid + } + return kind, 0, nil +} + func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) { if s == nil || s.gifts == nil { return CommandResult{}, fmt.Errorf("star gift service is not configured") @@ -833,8 +1086,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt } uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256) attributes[i] = domain.StarGiftCollectibleAttribute{ - Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille, - SortOrder: uploads[i].SortOrder, Animation: &animation, + Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille, + RarityPermille: uploads[i].RarityPermille, + SortOrder: uploads[i].SortOrder, Animation: &animation, } } return attributes, nil @@ -852,7 +1106,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt backdrops[i] = domain.StarGiftCollectibleAttribute{ Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID, CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor, - TextColor: backdrop.TextColor, RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder, + TextColor: backdrop.TextColor, RarityKind: domain.StarGiftRarityPermille, + RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder, } } write := domain.StarGiftCollectibleWrite{ @@ -873,8 +1128,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt } return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) { details := map[string]any{ - "gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal, - "slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models), + "gift_id": strconv.FormatInt(req.GiftID, 10), "upgrade_stars": strconv.FormatInt(req.UpgradeStars, 10), + "supply_total": req.SupplyTotal, + "slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models), "patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops), } if req.DryRun { @@ -884,7 +1140,7 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt if err != nil { return CommandResult{Details: details}, err } - details["revision_id"] = revision.ID + details["revision_id"] = strconv.FormatInt(revision.ID, 10) details["revision"] = revision.Revision details["published"] = revision.Published return CommandResult{Message: "star gift collectible pool published", Details: details}, nil @@ -907,7 +1163,7 @@ func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabled return CommandResult{}, fmt.Errorf("valid star gift and service are required") } return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) { - details := map[string]any{"gift_id": req.GiftID, "enabled": req.Enabled} + details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "enabled": req.Enabled} if req.DryRun { return CommandResult{Message: "star gift state change validated", Details: details}, nil } @@ -922,7 +1178,7 @@ func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortO return CommandResult{}, fmt.Errorf("valid star gift and service are required") } return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) { - details := map[string]any{"gift_id": req.GiftID, "sort_order": req.SortOrder} + details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "sort_order": req.SortOrder} if req.DryRun { return CommandResult{Message: "star gift order change validated", Details: details}, nil } diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index 209f6d56..f48c4182 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -1,15 +1,20 @@ package admin import ( + "bytes" "context" "crypto/sha256" + "encoding/hex" "errors" "reflect" "strings" "testing" "time" + stargiftapp "telesrv/internal/app/stargifts" "telesrv/internal/domain" + "telesrv/internal/officialgifts" + "telesrv/internal/store/memory" ) func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { @@ -710,7 +715,7 @@ func TestImportStarGiftDryRunThenConfirm(t *testing.T) { } base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false} result, err := svc.ImportStarGift(context.Background(), base) - if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(22) { + if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" { t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls) } } @@ -747,12 +752,161 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) { } base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false} result, err := svc.PublishStarGiftCollectibles(context.Background(), base) - if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(33) || result.Details["published"] != true { + if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true { t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls) } } -type fakeGiftsService struct{ createCalls int } +func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) { + permille := 922 + source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{ + ManifestSHA256: bytesOf(0x42, 32), + SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`), + Gift: officialgifts.Gift{ + ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1, + Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0, + AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75, + }, + BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")}, + Collectible: &officialgifts.CollectibleSet{ + Models: []officialgifts.Model{ + {Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}}, + {Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}}, + }, + Patterns: []officialgifts.Pattern{{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}}}, + Backdrops: []officialgifts.Backdrop{{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}}}, + }, + }} + gifts := &fakeGiftsService{} + svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow}) + req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true} + req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true} + preview, err := svc.ImportOfficialStarGift(context.Background(), req) + if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 { + t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls) + } + req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false} + result, err := svc.ImportOfficialStarGift(context.Background(), req) + if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" { + t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls) + } + models := gifts.lastBundle.Collectible.Models + if len(models) != 2 || !models[1].Crafted || models[1].RarityKind != domain.StarGiftRarityLegendary || models[1].RarityPermille != 0 || + models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 { + t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops) + } + catalog := gifts.lastBundle.Catalog + if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 || + catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 { + t.Fatalf("official global market state leaked into local catalog: %+v", catalog) + } + if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) || + !bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) { + t.Fatalf("official provenance was not preserved: %+v", catalog) + } +} + +func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) { + const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}` + document := func(id int64, name string) officialgifts.Document { + raw := []byte(lottie) + sum := sha256.Sum256(raw) + return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw} + } + permille := 1000 + source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{ + ManifestSHA256: bytesOf(0x24, sha256.Size), + SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`), + Gift: officialgifts.Gift{ + ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13, + UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1, + }, + BaseDocument: document(1, "gift.json"), + Collectible: &officialgifts.CollectibleSet{ + Models: []officialgifts.Model{{ + Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, + Document: document(2, "model.json"), + }}, + Patterns: []officialgifts.Pattern{{ + Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, + Document: document(3, "pattern.json"), + }}, + Backdrops: []officialgifts.Backdrop{{ + Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, + }}, + }, + }} + ctx := context.Background() + giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2) + svc := NewService(Dependencies{ + Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow, + }) + req := ImportOfficialStarGiftRequest{ + SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true, + CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false}, + } + result, err := svc.ImportOfficialStarGift(ctx, req) + if err != nil { + t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err) + } + catalog, err := giftService.Catalog(ctx) + if err != nil || len(catalog) != 1 { + t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err) + } + preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID) + if err != nil || !ok || len(preview.Models) != 1 || len(preview.Patterns) != 1 { + t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err) + } + model := preview.Models[0].Document + pattern := preview.Patterns[0].Document + if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil || + pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 || + pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 { + t.Fatalf("materialized model=%+v pattern=%+v", model, pattern) + } +} + +type adminGiftBlob struct{ data map[string][]byte } + +func (b *adminGiftBlob) Name() string { return "localfs" } +func (b *adminGiftBlob) Put(_ context.Context, data []byte) (string, error) { + sum := sha256.Sum256(data) + key := hex.EncodeToString(sum[:]) + b.data[key] = append([]byte(nil), data...) + return key, nil +} +func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) { + return append([]byte(nil), b.data[key]...), nil +} + +func bytesOf(value byte, count int) []byte { + out := make([]byte, count) + for i := range out { + out[i] = value + } + return out +} + +type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle } + +func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) { + return nil, nil +} +func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) { + if giftID != f.bundle.Gift.ID { + return officialgifts.Bundle{}, officialgifts.ErrNotFound + } + out := f.bundle + if !include { + out.Collectible = nil + } + return out, nil +} + +type fakeGiftsService struct { + createCalls int + lastBundle domain.StarGiftCatalogBundleWrite +} func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) { sum := sha256.Sum256(data) @@ -761,10 +915,24 @@ func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.St JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30, }, nil } +func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) { + return f.PrepareAnimation(name, data) +} func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { f.createCalls++ return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil } +func (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + f.createCalls++ + f.lastBundle = write + entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1} + result := domain.StarGiftCatalogBundleResult{Catalog: entry} + if write.Collectible != nil { + revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true} + result.Collectible = &revision + } + return result, nil +} func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) { return true, nil } diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index 0312bb71..e7f07f14 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,6 +16,7 @@ import ( "telesrv/internal/admin" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) type Config struct { @@ -32,6 +34,9 @@ type Service interface { DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) + ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) + OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) + OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error) @@ -95,6 +100,9 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages)) mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory)) mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift)) + mux.HandleFunc("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts)) + mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation)) + mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift)) mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles)) mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled)) mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder)) @@ -221,6 +229,60 @@ func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) { writeCommandResult(w, result, err) } +func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) { + items, err := s.svc.OfficialStarGifts(r.Context()) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, officialgifts.ErrUnavailable) { + status = http.StatusServiceUnavailable + } + writeError(w, status, err.Error()) + return + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + result = append(result, officialStarGiftListItem(item)) + } + writeJSON(w, http.StatusOK, map[string]any{"gifts": result}) +} + +func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any { + return map[string]any{ + "source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title, + "stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10), + "upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10), + "availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut, + "model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount, + "crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(), + "document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated, + } +} + +func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) { + raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id")) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !found { + writeError(w, http.StatusNotFound, "official gift animation not found") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "private, max-age=60") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(raw) +} + +func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) { + var req admin.ImportOfficialStarGiftRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.ImportOfficialStarGift(r.Context(), req) + writeCommandResult(w, result, err) +} + func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) @@ -338,7 +400,7 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque return } if !found { - writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID}) + writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)}) return } writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview)) @@ -347,8 +409,10 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any { attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any { result := map[string]any{ - "id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille, - "sort_order": value.SortOrder, "kind": value.Kind, + "id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind, + "rarity_permille": value.RarityPermille, "crafted": value.Crafted, + "official_document_id": strconv.FormatInt(value.OfficialDocumentID, 10), + "sort_order": value.SortOrder, "kind": value.Kind, } if value.Animation != nil { result["source_name"] = value.Animation.SourceName @@ -371,8 +435,9 @@ func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[strin return result } return map[string]any{ - "found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars, - "supply_total": preview.SupplyTotal, "issued": preview.Issued, + "found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision, + "upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10), + "supply_total": preview.SupplyTotal, "issued": preview.Issued, "slug_prefix": preview.SlugPrefix, "models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns), "backdrops": mapAttributes(preview.Backdrops), diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index e031bc83..eb97efd4 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -11,6 +11,7 @@ import ( "telesrv/internal/admin" "telesrv/internal/domain" + "telesrv/internal/officialgifts" ) func TestAdminAPIRequiresBearerToken(t *testing.T) { @@ -151,6 +152,49 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) { } } +func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T) { + const maxInt64 = int64(9223372036854775807) + got := collectiblePreviewResponse(domain.StarGiftUpgradePreview{ + GiftID: maxInt64, + UpgradeStars: maxInt64, + Models: []domain.StarGiftCollectibleAttribute{{ + ID: maxInt64, + Kind: domain.StarGiftCollectibleModel, + Name: "Exact", + RarityKind: domain.StarGiftRarityPermille, + RarityPermille: 1000, + OfficialDocumentID: maxInt64, + }}, + }) + if got["gift_id"] != "9223372036854775807" || got["upgrade_stars"] != "9223372036854775807" { + t.Fatalf("preview ids = %#v", got) + } + models, ok := got["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("preview models = %#v", got["models"]) + } + if models[0]["id"] != "9223372036854775807" || models[0]["official_document_id"] != "9223372036854775807" { + t.Fatalf("preview model ids = %#v", models[0]) + } +} + +func TestOfficialStarGiftListItemExposesExplicitCapabilities(t *testing.T) { + item := officialStarGiftListItem(officialgifts.GiftSummary{ + ID: 9223372036854775807, Title: "Fresh Socks", Stars: 25, ConvertStars: 10, UpgradeStars: 50, + ModelCount: 10, PatternCount: 20, BackdropCount: 30, CraftedModelCount: 2, + }) + if item["source_gift_id"] != "9223372036854775807" || item["title"] != "Fresh Socks" || + item["can_upgrade"] != true || item["can_craft"] != true { + t.Fatalf("official gift item = %#v", item) + } + item = officialStarGiftListItem(officialgifts.GiftSummary{ + ID: 1, UpgradeStars: 0, ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1, + }) + if item["can_upgrade"] != false || item["can_craft"] != false { + t.Fatalf("unavailable official gift capabilities = %#v", item) + } +} + type fakeService struct{} type captureFreezeService struct { @@ -219,6 +263,18 @@ func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftReq return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } +func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) { + return nil, nil +} + +func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) { + return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil +} + func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) { return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } diff --git a/internal/app/account/lifecycle.go b/internal/app/account/lifecycle.go new file mode 100644 index 00000000..e59954f0 --- /dev/null +++ b/internal/app/account/lifecycle.go @@ -0,0 +1,361 @@ +package account + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "telesrv/internal/branding" + "telesrv/internal/domain" + "telesrv/internal/otpdelivery" + "telesrv/internal/store" +) + +const accountDeletionDelay = 7 * 24 * time.Hour + +// DeleteAccount implements the official 2FA deletion decision. A supplied and +// valid SRP proof always deletes immediately. Without a proof, an account whose +// password is older than seven days and which was active during the last seven +// days gets a cancellable seven-day window; all other cases delete immediately. +func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) { + if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) { + return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden + } + if now.IsZero() { + now = time.Now().UTC() + } + reason = strings.TrimSpace(reason) + if len(reason) > 1024 { + return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden + } + snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID) + if err != nil { + return domain.AccountDeleteOutcome{}, err + } + if !found { + return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound + } + if snapshot.User.Deleted { + return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil + } + if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) { + return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden + } + if password != nil && !password.Empty { + if !snapshot.HasPassword { + return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid + } + if err := s.CheckPassword(ctx, userID, *password); err != nil { + return domain.AccountDeleteOutcome{}, err + } + return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now) + } + if !snapshot.HasPassword { + return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now) + } + lastActive := snapshot.User.CreatedAt + if snapshot.User.LastSeenAt > 0 { + seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC() + if seen.After(lastActive) { + lastActive = seen + } + } + passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay)) + recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay)) + if !passwordOldEnough || !recentlyActive { + return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now) + } + if snapshot.Pending != nil { + return delayedDeleteOutcome(*snapshot.Pending, now), nil + } + rawToken, digest, err := newAccountDeletionToken() + if err != nil { + return domain.AccountDeleteOutcome{}, err + } + executeAt := now.Add(accountDeletionDelay) + message := fmt.Sprintf( + "A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s", + url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken), + ) + pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ + UserID: userID, + RequesterAuthKeyID: authKeyID, + Reason: reason, + ConfirmHashDigest: digest, + ServiceMessage: message, + RequestedAt: now, + ExecuteAt: executeAt, + }) + if err != nil { + return domain.AccountDeleteOutcome{}, err + } + return delayedDeleteOutcome(pending, now), nil +} + +func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) { + result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now) + if err != nil { + return domain.AccountDeleteOutcome{}, err + } + if s.userCache != nil { + _ = s.userCache.Delete(ctx, []int64{userID}) + } + return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil +} + +func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome { + wait := int(time.Until(pending.ExecuteAt).Seconds()) + if !now.IsZero() { + wait = int(pending.ExecuteAt.Sub(now).Seconds()) + } + if wait < 0 { + wait = 0 + } + return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt} +} + +func deletionSourceForReason(reason string) domain.AccountDeletionSource { + switch strings.ToLower(strings.TrimSpace(reason)) { + case "forgot password": + return domain.AccountDeletionForgotPassword + case "decline tos update": + return domain.AccountDeletionTOSDecline + default: + return domain.AccountDeletionManual + } +} + +func newAccountDeletionToken() (string, [32]byte, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err) + } + token := hex.EncodeToString(raw[:]) + return token, sha256.Sum256([]byte(token)), nil +} + +func accountDeletionDigest(raw string) ([32]byte, error) { + raw = strings.TrimSpace(raw) + decoded, err := hex.DecodeString(raw) + if err != nil || len(decoded) != 32 { + return [32]byte{}, domain.ErrAccountDeletionHashInvalid + } + return sha256.Sum256([]byte(raw)), nil +} + +// SendConfirmPhoneCode validates the secret confirmphone link and issues an +// auth-key-scoped SMS code to the account's current phone. +func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) { + digest, err := accountDeletionDigest(rawHash) + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) { + return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid + } + if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil { + return "", domain.AuthCodeDelivery{}, err + } else if !found { + return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + if !found || u.Deleted || u.Phone == "" { + return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid + } + return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest) +} + +func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) { + hash, err := phoneChangeHash() + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + code := s.phoneChangeCode + channel := store.PhoneCodeChannelPhone + deliveryID := "" + if s.phoneCodeSender != nil { + code, err = randomDigits(s.phoneCodeLength) + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + deliveryID, err = otpdelivery.NewDeliveryID() + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + channel = store.PhoneCodeChannelSMS + } + if strings.TrimSpace(code) == "" { + return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured") + } + rec := store.PhoneCode{ + Version: store.PhoneCodeVersionCurrent, + Phone: phone, + Code: code, + DeliveryID: deliveryID, + Channel: channel, + Purpose: store.PhoneCodePurposeConfirmPhone, + UserID: userID, + AuthKeyID: authKeyID, + SessionID: sessionID, + MaxAttempts: s.phoneChangeMaxAttempts, + AccountDeletionHash: hex.EncodeToString(digest[:]), + } + expiresAt := time.Now().Add(s.phoneChangeCodeTTL) + if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil { + return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err) + } + if s.phoneCodeSender != nil { + if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: otpdelivery.PurposeConfirmPhone, + Channel: otpdelivery.ChannelSMS, + Recipient: phone, + Code: code, + ExpiresAt: expiresAt, + }); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil { + return "", domain.AuthCodeDelivery{}, errors.Join(err, cleanupErr) + } + return "", domain.AuthCodeDelivery{}, err + } + } + return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil +} + +// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and +// revokes the auth key that initiated the deletion attempt. +func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) { + if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" { + return nil, domain.ErrPhoneCodeEmpty + } + if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil { + return nil, domain.ErrPhoneCodeInvalid + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return nil, err + } + if !found || u.Deleted || u.Phone == "" { + return nil, domain.ErrPhoneCodeInvalid + } + scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone} + verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts) + if err != nil { + return nil, err + } + switch verified.Status { + case store.LoginCodeVerifyMissing: + return nil, domain.ErrPhoneCodeExpired + case store.LoginCodeVerifyInvalid: + return nil, domain.ErrPhoneCodeInvalid + case store.LoginCodeVerifyAccepted: + default: + return nil, domain.ErrPhoneCodeInvalid + } + digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash) + if err != nil || len(digestBytes) != 32 { + return nil, domain.ErrPhoneCodeInvalid + } + var digest [32]byte + copy(digest[:], digestBytes) + if now.IsZero() { + now = time.Now().UTC() + } + return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now) +} + +// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is +// an active confirm-phone code for this authorized user/auth key. +func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) { + if s == nil || s.codes == nil || s.users == nil || userID == 0 { + return "", domain.AuthCodeDelivery{}, false, nil + } + rec, found, err := s.codes.Get(ctx, oldHash) + if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID { + return "", domain.AuthCodeDelivery{}, false, err + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) { + if err == nil { + err = domain.ErrPhoneCodeInvalid + } + return "", domain.AuthCodeDelivery{}, true, err + } + consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope()) + if err != nil || !found { + if err == nil { + err = domain.ErrPhoneCodeExpired + } + return "", domain.AuthCodeDelivery{}, true, err + } + digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash) + if err != nil || len(digestBytes) != 32 { + return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid + } + var digest [32]byte + copy(digest[:], digestBytes) + hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest) + return hash, delivery, true, err +} + +func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) { + if s == nil || s.codes == nil || userID == 0 { + return false, nil + } + rec, found, err := s.codes.Get(ctx, hash) + if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID { + return false, err + } + if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) { + return true, domain.ErrPhoneCodeInvalid + } + _, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope()) + return true, err +} + +func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) { + if s == nil || s.lifecycle == nil || limit <= 0 { + return nil, nil + } + candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit) + if err != nil { + return nil, err + } + out := make([]domain.AccountDeletionResult, 0, len(candidates)) + for _, candidate := range candidates { + result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now) + if err != nil { + return out, err + } + if s.userCache != nil { + _ = s.userCache.Delete(ctx, []int64{candidate.UserID}) + } + out = append(out, result) + } + return out, nil +} + +func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) { + if s == nil || s.lifecycle == nil { + return nil, nil + } + return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease) +} + +func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error { + if s == nil || s.lifecycle == nil { + return nil + } + return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now) +} diff --git a/internal/app/account/lifecycle_test.go b/internal/app/account/lifecycle_test.go new file mode 100644 index 00000000..f5eca1f7 --- /dev/null +++ b/internal/app/account/lifecycle_test.go @@ -0,0 +1,149 @@ +package account + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + authKey := [8]byte{1} + tests := []struct { + name string + hasPassword bool + passwordUpdated time.Time + createdAt time.Time + lastSeen int + wantKind domain.AccountDeleteKind + }{ + {name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate}, + {name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed}, + {name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate}, + {name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{ + User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen}, + HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated, + }} + svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle)) + outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now) + if err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + if outcome.Kind != test.wantKind { + t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind) + } + if test.wantKind == domain.AccountDeleteDelayed { + if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) { + t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled) + } + } else if lifecycle.executedSource == "" { + t.Fatal("immediate path did not execute the tombstone boundary") + } + }) + } +} + +func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_800_000_000, 0).UTC() + users := memory.NewUserStore() + u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"}) + if err != nil { + t.Fatal(err) + } + requester := [8]byte{9} + confirming := [8]byte{8} + lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}} + svc := NewService(memory.NewPasswordStore(), + WithUsers(users), + WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5), + WithAccountLifecycle(lifecycle), + ) + rawToken, digest, err := newAccountDeletionToken() + if err != nil { + t.Fatal(err) + } + lifecycle.pending = &domain.AccountDeletionRequest{ + ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending, + ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay), + } + hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken) + if err != nil || hash == "" || delivery.Length != 5 { + t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err) + } + revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute)) + if err != nil { + t.Fatalf("ConfirmPhone: %v", err) + } + if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil { + t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending) + } + if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) { + t.Fatalf("replay error = %v, want expired", err) + } +} + +type fakeAccountLifecycleStore struct { + snapshot domain.AccountDeletionSnapshot + pending *domain.AccountDeletionRequest + scheduled *domain.ScheduleAccountDeletion + executedSource domain.AccountDeletionSource +} + +func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) { + f.snapshot.Pending = f.pending + return f.snapshot, true, nil +} + +func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) { + f.scheduled = &req + pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt} + f.pending = &pending + return pending, true, nil +} + +func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) { + if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest { + return domain.AccountDeletionRequest{}, false, nil + } + return *f.pending, true, nil +} + +func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) { + f.executedSource = source + u := f.snapshot.User + u.Deleted = true + u.DeletedAt = now.Unix() + u.DeletionSource = source + u.DeletionReason = reason + u = u.DeletedTombstone() + return domain.AccountDeletionResult{User: u, Changed: true}, nil +} + +func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) { + if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest { + return nil, domain.ErrAccountDeletionHashInvalid + } + revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}} + f.pending = nil + return revoked, nil +} + +func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) { + return nil, nil +} +func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) { + return nil, nil +} +func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error { + return nil +} diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 392e2bd8..653641f8 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -43,6 +43,7 @@ type Service struct { userCache store.UserCache authorizations store.AuthorizationStore phoneChanges store.PhoneChangeStore + lifecycle store.AccountLifecycleStore publicBaseURL string codes store.CodeStore phoneChangeCode string @@ -189,6 +190,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption } } +// WithAccountLifecycle installs the single durable account deletion boundary. +// It shares the already configured phone-code delivery and user cache. +func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption { + return func(s *Service) { + s.lifecycle = lifecycle + } +} + // NewService 创建 account 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{ diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index d5f74403..8ebfc5a8 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -17,6 +17,7 @@ import ( "github.com/iamxvbaba/td/bin" mtcrypto "github.com/iamxvbaba/td/crypto" + "telesrv/internal/branding" "telesrv/internal/domain" "telesrv/internal/otpdelivery" "telesrv/internal/store" @@ -1547,9 +1548,9 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error return found && settings.HasPassword, nil } -const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! +const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `! -This code can be used to log in to your Telegram account. We never ask it for anything else. +This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else. If you didn't request this code by trying to log in on another device, simply ignore this message.` diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 4a8023da..d49bdc62 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" + "telesrv/internal/branding" "telesrv/internal/domain" ) @@ -42,7 +43,7 @@ const ( botFatherDraftBotUsername = "bot_username" ) -const botFatherHelpText = `I can help you create and manage Telegram bots. +const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots. You can control me by sending these commands: diff --git a/internal/app/bots/manage_test.go b/internal/app/bots/manage_test.go index 39be659d..ca664613 100644 --- a/internal/app/bots/manage_test.go +++ b/internal/app/bots/manage_test.go @@ -27,7 +27,7 @@ func TestSetBotCommandsAndBump(t *testing.T) { before, _, _ := users.ByID(ctx, bot.ID) v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{ - {Command: "/Start", Description: "begin"}, + {Command: "/Start", Description: "begin", Ephemeral: true}, {Command: "help", Description: "show help"}, }) if err != nil { @@ -40,7 +40,7 @@ func TestSetBotCommandsAndBump(t *testing.T) { if err != nil { t.Fatalf("get commands: %v", err) } - if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" { + if len(got) != 2 || got[0].Command != "start" || !got[0].Ephemeral || got[1].Command != "help" || got[1].Ephemeral { t.Fatalf("commands = %+v, want normalized [start,help]", got) } diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index b24f15ce..b715acfc 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -497,7 +497,7 @@ func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen { return 0, domain.ErrBotCommandInvalid } - clean = append(clean, domain.BotCommand{Command: cmd, Description: desc}) + clean = append(clean, domain.BotCommand{Command: cmd, Description: desc, Ephemeral: c.Ephemeral}) } // 同值短路:bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的 // bot_info_version bump(驱动全体客户端多打一轮 getFullUser)与多余推送。 @@ -528,7 +528,7 @@ func botCommandsEqual(a, b []domain.BotCommand) bool { return false } for i := range a { - if a[i].Command != b[i].Command || a[i].Description != b[i].Description { + if a[i].Command != b[i].Command || a[i].Description != b[i].Description || a[i].Ephemeral != b[i].Ephemeral { return false } } diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 43330a04..222e6edd 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -508,6 +508,15 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ( return s.channels.ListAdminedPublicChannels(ctx, userID) } +// ListCommunityLinkableChannels returns owned/administered channels that are not +// already linked to another Community. Private megagroups are valid candidates. +func (s *Service) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, nil + } + return s.channels.ListCommunityLinkableChannels(ctx, userID) +} + // ListStoryPostableChannels returns channels where user can publish stories. func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 { @@ -1276,6 +1285,9 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send if req.UserID != userID { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.SendChannelMessageResult{}, err + } if req.RandomID != 0 && !req.IdempotencyPreflighted { fingerprint, err := store.ChannelSendFingerprint(req) if err != nil { @@ -1343,6 +1355,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditChannelMessageResult{}, err + } + } return s.channels.EditChannelMessage(ctx, req) } @@ -1359,6 +1376,11 @@ func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req dom if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditChannelMessageResult{}, err + } + } req.ViaBotEditBotID = botID return s.channels.EditChannelMessage(ctx, req) } diff --git a/internal/app/communities/service.go b/internal/app/communities/service.go new file mode 100644 index 00000000..2c14b1f4 --- /dev/null +++ b/internal/app/communities/service.go @@ -0,0 +1,185 @@ +package communities + +import ( + "context" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service owns Community business validation. The store is the aggregate +// transaction boundary because link changes span community/link and peer rows. +type Service struct { + communities store.CommunityStore +} + +func NewService(communities store.CommunityStore) *Service { + return &Service{communities: communities} +} + +func validPeer(peer domain.Peer) bool { + return peer.ID > 0 && (peer.Type == domain.PeerTypeChannel || peer.Type == domain.PeerTypeUser) +} + +func validVisibility(v domain.CommunityPeerVisibility) bool { + return v == domain.CommunityPeerVisible || v == domain.CommunityPeerHidden +} + +func (s *Service) Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 || !validPeer(req.InitialPeer) || !validVisibility(req.Visibility) { + return domain.CommunityView{}, domain.ErrCommunityInvalid + } + req.CreatorUserID = userID + req.Title = strings.TrimSpace(req.Title) + req.About = strings.TrimSpace(req.About) + if req.Title == "" || utf8.RuneCountInString(req.Title) > domain.MaxCommunityTitleRunes { + return domain.CommunityView{}, domain.ErrChannelTitleInvalid + } + if utf8.RuneCountInString(req.About) > domain.MaxCommunityAboutRunes { + return domain.CommunityView{}, domain.ErrAboutTooLong + } + return s.communities.CreateCommunity(ctx, req) +} + +func (s *Service) Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityView{}, domain.ErrCommunityInvalid + } + return s.communities.GetCommunity(ctx, userID, communityID) +} + +func (s *Service) GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.GetCommunities(ctx, userID, ids) +} + +func (s *Service) ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.ListJoinedCommunities(ctx, userID) +} + +func (s *Service) TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || req.CommunityID == 0 || !validPeer(req.Peer) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid + } + if !req.Deleted && !validVisibility(req.Visibility) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + req.ActorUserID = userID + return s.communities.ToggleCommunityPeerLink(ctx, req) +} + +func (s *Service) SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityView{}, false, domain.ErrCommunityInvalid + } + return s.communities.SetCommunityCollapsed(ctx, userID, communityID, collapsed) +} + +func (s *Service) ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityInvalid + } + if limit <= 0 || limit > domain.MaxCommunityLinkRequests { + limit = domain.MaxCommunityLinkRequests + } + return s.communities.ListCommunityPeerLinkRequests(ctx, userID, communityID, offset, limit) +} + +func (s *Service) DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || !validPeer(peer) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid + } + return s.communities.DecideCommunityPeerLinkRequest(ctx, userID, communityID, peer, reject, date) +} + +func (s *Service) DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.DecideAllCommunityPeerLinkRequests(ctx, userID, communityID, reject, date) +} + +func (s *Service) ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityInvalid + } + return s.communities.ToggleCommunityParticipantBanned(ctx, userID, communityID, participantUserID, unban, date) +} + +func (s *Service) ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityInvalid + } + return s.communities.GetCommunityParticipantJoinedChats(ctx, userID, communityID, participantUserID) +} + +func (s *Service) Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityParticipantList{}, domain.ErrCommunityInvalid + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxCommunityParticipants { + limit = domain.MaxCommunityParticipants + } + return s.communities.ListCommunityParticipants(ctx, userID, communityID, filter, offset, limit) +} + +func (s *Service) EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error) { + title = strings.TrimSpace(title) + if title == "" || utf8.RuneCountInString(title) > domain.MaxCommunityTitleRunes { + return domain.CommunityView{}, false, domain.ErrChannelTitleInvalid + } + return s.communities.EditCommunityTitle(ctx, userID, communityID, title) +} + +func (s *Service) EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error) { + about = strings.TrimSpace(about) + if utf8.RuneCountInString(about) > domain.MaxCommunityAboutRunes { + return domain.CommunityView{}, false, domain.ErrAboutTooLong + } + return s.communities.EditCommunityAbout(ctx, userID, communityID, about) +} + +func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + if req.CommunityID == 0 || req.UserID == 0 || userID == 0 { + return domain.CommunityView{}, false, domain.ErrCommunityInvalid + } + req.ActorUserID = userID + return s.communities.EditCommunityAdmin(ctx, req) +} + +func (s *Service) EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + return s.communities.EditCommunityDefaultBannedRights(ctx, userID, communityID, rights) +} + +func (s *Service) SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + return s.communities.SetCommunityPhoto(ctx, userID, communityID, photo, date) +} + +func (s *Service) Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) { + return s.communities.DeleteCommunity(ctx, userID, communityID, date) +} + +func (s *Service) SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) { + return s.communities.SetCommunityPinned(ctx, userID, communityID, pinned) +} + +func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) { + return s.communities.ReorderCommunityPinned(ctx, userID, order, force) +} + +func (s *Service) SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error) { + return s.communities.CommunitySearchScope(ctx, userID, communityID) +} diff --git a/internal/app/dialogs/read_model_cache.go b/internal/app/dialogs/read_model_cache.go index 0e9aa838..9f6dcce6 100644 --- a/internal/app/dialogs/read_model_cache.go +++ b/internal/app/dialogs/read_model_cache.go @@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa if in == nil { return nil } - out := &domain.MessageReplyMarkup{} + out := &domain.MessageReplyMarkup{ + Type: in.Type, + Resize: in.Resize, + SingleUse: in.SingleUse, + Selective: in.Selective, + Persistent: in.Persistent, + Placeholder: in.Placeholder, + } if len(in.Inline) > 0 { out.Inline = make([][]domain.MarkupButton, len(in.Inline)) for i, row := range in.Inline { @@ -472,6 +479,12 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa } } } + if len(in.Keyboard) > 0 { + out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard)) + for i, row := range in.Keyboard { + out.Keyboard[i] = append([]domain.MarkupButton(nil), row...) + } + } return out } diff --git a/internal/app/ephemeral/service.go b/internal/app/ephemeral/service.go new file mode 100644 index 00000000..ef330864 --- /dev/null +++ b/internal/app/ephemeral/service.go @@ -0,0 +1,617 @@ +package ephemeral + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "strings" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type ChannelAccess interface { + ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) + GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) +} + +type UserDirectory interface { + ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) +} + +type BotCommands interface { + GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithIDGenerator(next func() (int, error)) Option { + return func(s *Service) { + if next != nil { + s.nextID = next + } + } +} + +type Service struct { + messages store.EphemeralMessageStore + channels ChannelAccess + users UserDirectory + bots BotCommands + now func() time.Time + nextID func() (int, error) +} + +func NewService(messages store.EphemeralMessageStore, channels ChannelAccess, users UserDirectory, bots BotCommands, options ...Option) *Service { + s := &Service{ + messages: messages, + channels: channels, + users: users, + bots: bots, + now: time.Now, + nextID: randomEphemeralID, + } + for _, option := range options { + if option != nil { + option(s) + } + } + return s +} + +func (s *Service) SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) { + if s == nil || s.messages == nil || s.channels == nil || s.users == nil || s.bots == nil { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + if request.SenderUserID <= 0 || request.ReceiverBotID <= 0 || request.SenderUserID == request.ReceiverBotID || + request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 || request.RandomID == 0 || + request.OriginDevice.UserID != request.SenderUserID || request.OriginDevice.BusinessAuthKeyID == ([8]byte{}) || + request.OriginDevice.SessionID == 0 || !validContent(request.Content) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + view, err := s.requireActiveGroupPair(ctx, request.SenderUserID, request.ReceiverBotID, request.Peer.ID) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + receiver, found, err := s.users.ByID(ctx, request.SenderUserID, request.ReceiverBotID) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || !receiver.Bot || receiver.Deleted { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid + } + var replyTarget *domain.EphemeralMessage + if request.ReplyToEphemeralID != 0 { + target, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, s.now()) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || target.Deleted || target.SenderUserID != request.ReceiverBotID || target.ReceiverUserID != request.SenderUserID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired + } + if target.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && target.OriginDevice.BusinessAuthKeyID != request.OriginDevice.BusinessAuthKeyID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch + } + if request.TopMessageID != 0 && request.TopMessageID != target.TopMessageID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid + } + request.TopMessageID = target.TopMessageID + replyTarget = &target + } else { + allowed, err := s.isEphemeralCommand(ctx, receiver, request.Content.Message) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !allowed { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralCommandInvalid + } + } + if err := s.validateForumTopic(ctx, request.SenderUserID, view, request.TopMessageID); err != nil { + return domain.EphemeralMessage{}, false, err + } + message, fresh, err := s.create(ctx, domain.EphemeralMessage{ + Peer: request.Peer, + SenderUserID: request.SenderUserID, + ReceiverUserID: request.ReceiverBotID, + RandomID: request.RandomID, + TopMessageID: request.TopMessageID, + ReplyToEphemeralID: request.ReplyToEphemeralID, + Content: request.Content, + OriginDevice: request.OriginDevice, + PayloadHash: clientPayloadHash(request), + }) + if err == nil && replyTarget != nil { + message.BotAPIReply = replyTarget + } + return message, fresh, err +} + +func (s *Service) SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) { + return s.sendFromBot(ctx, request, func(context.Context) (domain.EphemeralContent, error) { + return request.Content, nil + }) +} + +// SendFromBotLazy authorizes the bot, receiver, chat and eligible action before +// materializing content. The RPC edge uses it for URL/upload media so an +// unauthorized target cannot consume file storage, network or decoder work. +func (s *Service) SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) { + if build == nil { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + return s.sendFromBot(ctx, request, build) +} + +func (s *Service) sendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) { + if s == nil || s.messages == nil || s.channels == nil || s.users == nil { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + if request.BotUserID <= 0 || request.ReceiverUserID <= 0 || request.BotUserID == request.ReceiverUserID || + request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + view, err := s.requireActiveGroupPair(ctx, request.BotUserID, request.ReceiverUserID, request.Peer.ID) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + bot, found, err := s.users.ByID(ctx, request.BotUserID, request.BotUserID) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || !bot.Bot || bot.Deleted { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralSenderInvalid + } + receiver, found, err := s.users.ByID(ctx, request.BotUserID, request.ReceiverUserID) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || receiver.Bot || receiver.Deleted { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid + } + now := s.now() + var targetDevice domain.EphemeralDevice + var replyTarget *domain.EphemeralMessage + if request.ActionMessageID != 0 && request.CallbackQueryID != 0 { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + if request.CallbackQueryID != 0 { + action, found, err := s.messages.GetEphemeralCallbackAction(ctx, request.BotUserID, request.CallbackQueryID, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || action.UserID != request.ReceiverUserID || action.Peer != request.Peer || !now.Before(action.ExpiresAt) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired + } + targetDevice = action.Device + if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid + } + request.TopMessageID = action.TopMessageID + } else if request.ActionMessageID != 0 { + action, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ActionMessageID, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found || action.Deleted || action.SenderUserID != request.ReceiverUserID || action.ReceiverUserID != request.BotUserID || + now.Sub(action.CreatedAt) < 0 || now.Sub(action.CreatedAt) > domain.EphemeralReplyWindow { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired + } + targetDevice = action.OriginDevice + replyTarget = &action + if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid + } + request.TopMessageID = action.TopMessageID + if request.ReplyToEphemeralID == 0 { + request.ReplyToEphemeralID = action.ID + } + } else { + if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden + } + } + if request.ReplyToEphemeralID != 0 { + var reply domain.EphemeralMessage + found := false + if replyTarget != nil && replyTarget.ID == request.ReplyToEphemeralID { + reply, found = *replyTarget, true + } else { + var err error + reply, found, err = s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + } + if !found || reply.Deleted || !sameEphemeralParticipants(reply, request.BotUserID, request.ReceiverUserID) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired + } + if targetDevice.BusinessAuthKeyID != ([8]byte{}) && reply.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && + targetDevice.BusinessAuthKeyID != reply.OriginDevice.BusinessAuthKeyID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch + } + if request.TopMessageID != 0 && request.TopMessageID != reply.TopMessageID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid + } + request.TopMessageID = reply.TopMessageID + replyTarget = &reply + } + if err := s.validateForumTopic(ctx, request.BotUserID, view, request.TopMessageID); err != nil { + return domain.EphemeralMessage{}, false, err + } + content, err := build(ctx) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !validContent(content) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + request.Content = content + if request.RandomID == 0 { + request.RandomID, err = randomEphemeralRandomID() + if err != nil { + return domain.EphemeralMessage{}, false, err + } + } + message, fresh, err := s.create(ctx, domain.EphemeralMessage{ + Peer: request.Peer, + SenderUserID: request.BotUserID, + ReceiverUserID: request.ReceiverUserID, + RandomID: request.RandomID, + TopMessageID: request.TopMessageID, + ReplyToEphemeralID: request.ReplyToEphemeralID, + Content: request.Content, + OriginDevice: targetDevice, + PayloadHash: botPayloadHash(request), + }) + if err == nil && replyTarget != nil { + message.BotAPIReply = replyTarget + } + return message, fresh, err +} + +func (s *Service) EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) { + now := s.now() + message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, err + } + if !found { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if message.SenderUserID != botUserID { + return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden + } + return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now) +} + +func (s *Service) EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) { + return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, func(context.Context) (domain.EditEphemeralFields, error) { + return fields, nil + }) +} + +// EditFieldsFromBotLazy performs the identity/ownership lookup before building +// replacement media. This keeps invalid edit requests off the remote-fetch and +// blob-materialization paths while preserving a single CAS write on success. +func (s *Service) EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) { + if build == nil { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, build) +} + +func (s *Service) editFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) { + now := s.now() + message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, err + } + if !found { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if message.SenderUserID != botUserID || message.ReceiverUserID != receiverUserID { + return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden + } + fields, err := build(ctx) + if err != nil { + return domain.EphemeralMessage{}, err + } + switch mode { + case domain.EphemeralEditText: + if message.Content.Media != nil || !message.Content.RichMessage.IsZero() || !fields.SetMessage { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + case domain.EphemeralEditCaption: + if message.Content.Media == nil || !fields.SetMessage { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + case domain.EphemeralEditMedia: + if message.Content.Media == nil || !fields.SetMedia { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + case domain.EphemeralEditReplyMarkup: + if !fields.SetReplyMarkup || fields.SetMessage || fields.SetMedia { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + default: + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + content := message.Content + if fields.SetMessage { + content.Message = fields.Message + content.Entities = append([]domain.MessageEntity(nil), fields.Entities...) + } + if fields.SetMedia { + content.Media = fields.Media + } + if fields.SetReplyMarkup { + content.ReplyMarkup = fields.ReplyMarkup + } + if !validContent(content) { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now) +} + +func (s *Service) Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) { + return s.delete(ctx, actorUserID, receiverUserID, nil, peer, id) +} + +func (s *Service) DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) { + if device.UserID != actorUserID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden + } + return s.delete(ctx, actorUserID, receiverUserID, &device, peer, id) +} + +func (s *Service) delete(ctx context.Context, actorUserID, receiverUserID int64, device *domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) { + now := s.now() + message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + } + if message.ReceiverUserID != receiverUserID || (actorUserID != message.SenderUserID && actorUserID != message.ReceiverUserID) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden + } + if device != nil && message.OriginDevice.UserID == actorUserID && message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && + message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch + } + return s.messages.DeleteEphemeralMessage(ctx, peer, id, message.Version, now) +} + +func (s *Service) Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) { + if len(data) > domain.MaxEphemeralCallbackDataBytes || userID <= 0 || device.UserID != userID || + device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 { + return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid + } + now := s.now() + message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralCallback{}, err + } + if !found || message.Deleted || message.ReceiverUserID != userID { + return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid + } + if !ephemeralMarkupContainsCallback(message.Content.ReplyMarkup, data) { + return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid + } + if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID { + return domain.EphemeralCallback{}, domain.ErrEphemeralDeviceMismatch + } + return domain.EphemeralCallback{ + Message: message, + BotUserID: message.SenderUserID, + UserID: userID, + Peer: peer, + Data: append([]byte(nil), data...), + Device: device, + OccurredAt: now, + }, nil +} + +func (s *Service) PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) { + if s == nil || s.messages == nil { + return false, domain.ErrEphemeralInvalid + } + return s.messages.PutEphemeralCallbackAction(ctx, action) +} + +func (s *Service) ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) { + if userID <= 0 || device.UserID != userID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 { + return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden + } + message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, s.now()) + if err != nil { + return domain.EphemeralMessage{}, err + } + if !found || message.Deleted || message.ReceiverUserID != userID { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID { + return domain.EphemeralMessage{}, domain.ErrEphemeralDeviceMismatch + } + return message, nil +} + +func ephemeralMarkupContainsCallback(markup *domain.MessageReplyMarkup, data []byte) bool { + if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline { + return false + } + for _, row := range markup.Inline { + for _, button := range row { + if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) { + return true + } + } + } + return false +} + +func sameEphemeralParticipants(message domain.EphemeralMessage, first, second int64) bool { + return (message.SenderUserID == first && message.ReceiverUserID == second) || + (message.SenderUserID == second && message.ReceiverUserID == first) +} + +func (s *Service) create(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) { + now := s.now() + message.Date = int(now.Unix()) + message.CreatedAt = now + message.ExpiresAt = now.Add(domain.EphemeralMessageRetention) + message.Version = 1 + for attempt := 0; attempt < domain.MaxEphemeralCreateAttempts; attempt++ { + id, err := s.nextID() + if err != nil { + return domain.EphemeralMessage{}, false, err + } + message.ID = id + created, fresh, err := s.messages.CreateEphemeralMessage(ctx, message) + if !errors.Is(err, domain.ErrEphemeralIDCollision) { + return created, fresh, err + } + } + return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision +} + +func (s *Service) requireActiveGroupPair(ctx context.Context, viewerUserID, otherUserID, channelID int64) (domain.ChannelView, error) { + view, err := s.channels.ResolveChannel(ctx, viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if view.Channel.Deleted || view.Channel.Broadcast || view.Channel.Monoforum || view.Self.Status != domain.ChannelMemberActive { + return domain.ChannelView{}, domain.ErrEphemeralPeerInvalid + } + other, err := s.channels.GetParticipant(ctx, viewerUserID, channelID, otherUserID) + if err != nil { + return domain.ChannelView{}, err + } + if other.Status != domain.ChannelMemberActive { + return domain.ChannelView{}, domain.ErrEphemeralReceiverInvalid + } + return view, nil +} + +func (s *Service) validateForumTopic(ctx context.Context, userID int64, view domain.ChannelView, topMessageID int) error { + if topMessageID == 0 { + return nil + } + if !view.Channel.Forum || topMessageID < 0 || topMessageID > domain.MaxMessageBoxID { + return domain.ErrEphemeralPeerInvalid + } + topics, err := s.channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, []int{topMessageID}) + if err != nil { + return err + } + if len(topics.Topics) != 1 || topics.Topics[0].TopicID != topMessageID || topics.Topics[0].Hidden { + return domain.ErrEphemeralPeerInvalid + } + if topics.Topics[0].Closed && view.Self.Role != domain.ChannelRoleAdmin && view.Self.Role != domain.ChannelRoleCreator { + return domain.ErrEphemeralForbidden + } + return nil +} + +func (s *Service) isEphemeralCommand(ctx context.Context, bot domain.User, message string) (bool, error) { + command, username, ok := parseCommand(message) + if !ok || (username != "" && !strings.EqualFold(username, bot.Username)) { + return false, nil + } + commands, err := s.bots.GetBotCommands(ctx, bot.ID) + if err != nil { + return false, err + } + for _, candidate := range commands { + if candidate.Ephemeral && strings.EqualFold(candidate.Command, command) { + return true, nil + } + } + return false, nil +} + +func parseCommand(message string) (command, username string, ok bool) { + fields := strings.Fields(strings.TrimSpace(message)) + if len(fields) == 0 || len(fields[0]) < 2 || fields[0][0] != '/' { + return "", "", false + } + parts := strings.SplitN(fields[0][1:], "@", 2) + command = strings.ToLower(parts[0]) + if command == "" { + return "", "", false + } + if len(parts) == 2 { + username = strings.TrimPrefix(strings.ToLower(parts[1]), "@") + if username == "" { + return "", "", false + } + } + return command, username, true +} + +func validContent(content domain.EphemeralContent) bool { + return domain.ValidateEphemeralContent(content) == nil +} + +func clientPayloadHash(request domain.SendClientEphemeralRequest) [32]byte { + return payloadHash(struct { + SenderUserID, ReceiverBotID int64 + Peer domain.Peer + QueryID, RandomID int64 + TopMessageID, ReplyID int + Content domain.EphemeralContent + Device domain.EphemeralDevice + }{request.SenderUserID, request.ReceiverBotID, request.Peer, request.QueryID, request.RandomID, + request.TopMessageID, request.ReplyToEphemeralID, request.Content, request.OriginDevice}) +} + +func botPayloadHash(request domain.SendBotEphemeralRequest) [32]byte { + return payloadHash(request) +} + +func payloadHash(value any) [32]byte { + raw, err := json.Marshal(value) + if err != nil { + return sha256.Sum256([]byte("invalid-ephemeral-payload")) + } + return sha256.Sum256(raw) +} + +func randomEphemeralID() (int, error) { + var raw [4]byte + if _, err := rand.Read(raw[:]); err != nil { + return 0, err + } + value := binary.LittleEndian.Uint32(raw[:]) & 0x7fffffff + if value == 0 { + value = 1 + } + return int(value), nil +} + +func randomEphemeralRandomID() (int64, error) { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return 0, err + } + value := int64(binary.LittleEndian.Uint64(raw[:])) + if value == 0 { + value = 1 + } + return value, nil +} diff --git a/internal/app/ephemeral/service_test.go b/internal/app/ephemeral/service_test.go new file mode 100644 index 00000000..c475b4c5 --- /dev/null +++ b/internal/app/ephemeral/service_test.go @@ -0,0 +1,385 @@ +package ephemeral + +import ( + "context" + "crypto/sha256" + "errors" + "strings" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +const ( + testHumanID int64 = 1001 + testBotID int64 = 2001 + testChannel int64 = 3001 + testSession int64 = 4001 +) + +var testDeviceKey = [8]byte{1, 2, 3, 4} + +type testChannels struct { + roles map[int64]domain.ChannelMemberRole + status map[int64]domain.ChannelMemberStatus + channel domain.Channel +} + +func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) { + if channelID != c.channel.ID { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{ + ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID], + }}, nil +} + +func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) { + if channelID != c.channel.ID { + return domain.ChannelMember{}, domain.ErrChannelInvalid + } + return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil +} + +func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + if channelID != c.channel.ID { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + out := domain.ChannelForumTopicList{Channel: c.channel} + for _, id := range ids { + if id > 0 { + out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id}) + } + } + return out, nil +} + +type testUsers map[int64]domain.User + +func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) { + user, found := u[userID] + return user, found, nil +} + +type testBots map[int64][]domain.BotCommand + +func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) { + return append([]domain.BotCommand(nil), b[botUserID]...), nil +} + +type serviceFixture struct { + service *Service + store *memory.EphemeralMessageStore + now time.Time + nextID int + channels *testChannels +} + +func newServiceFixture() *serviceFixture { + f := &serviceFixture{ + store: memory.NewEphemeralMessageStore(), + now: time.Unix(1_900_000_000, 0), + nextID: 10, + channels: &testChannels{ + roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember}, + status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive}, + channel: domain.Channel{ID: testChannel, Megagroup: true}, + }, + } + f.service = NewService(f.store, f.channels, testUsers{ + testHumanID: {ID: testHumanID, Username: "alice"}, + testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1}, + }, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}}, + WithClock(func() time.Time { return f.now }), + WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil })) + return f +} + +func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest { + return domain.SendClientEphemeralRequest{ + SenderUserID: testHumanID, ReceiverBotID: testBotID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel}, + RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"}, + OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}, + } +} + +func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) { + f := newServiceFixture() + message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest()) + if err != nil || !fresh { + t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err) + } + if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey { + t.Fatalf("message = %+v", message) + } + request := f.clientRequest() + request.RandomID++ + request.Content.Message = "/public" + if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) { + t.Fatalf("ordinary command err=%v", err) + } +} + +func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) { + f := newServiceFixture() + request := f.clientRequest() + message, fresh, err := f.service.SendFromClient(context.Background(), request) + if err != nil || !fresh { + t.Fatalf("create fresh=%v err=%v", fresh, err) + } + device := request.OriginDevice + if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed { + t.Fatalf("delete changed=%v err=%v", changed, err) + } + replayed, fresh, err := f.service.SendFromClient(context.Background(), request) + if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 { + t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err) + } +} + +func TestClientReplyMustMatchTargetDevice(t *testing.T) { + f := newServiceFixture() + incoming := f.putIncoming(t, testDeviceKey, f.now) + request := f.clientRequest() + request.Content.Message = "reply" + request.ReplyToEphemeralID = incoming.ID + reply, fresh, err := f.service.SendFromClient(context.Background(), request) + if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID { + t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err) + } + request.RandomID++ + request.OriginDevice.BusinessAuthKeyID = [8]byte{9} + if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) { + t.Fatalf("other device reply err=%v", err) + } +} + +func TestBotReplyWindowAndAdminBroadcast(t *testing.T) { + f := newServiceFixture() + action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest()) + if err != nil { + t.Fatal(err) + } + f.now = f.now.Add(14 * time.Second) + reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID, + Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID, + }) + if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID || + reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID { + t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err) + } + f.now = f.now.Add(2 * time.Second) + if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer, + RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID, + }); !errors.Is(err, domain.ErrEphemeralReplyExpired) { + t.Fatalf("late bot reply err=%v", err) + } + f.channels.roles[testBotID] = domain.ChannelRoleAdmin + broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer, + RandomID: 94, Content: domain.EphemeralContent{Message: "admin"}, + }) + if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) { + t.Fatalf("admin broadcast = %+v err=%v", broadcast, err) + } +} + +func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) { + f := newServiceFixture() + incoming := f.putIncoming(t, testDeviceKey, f.now) + device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession} + callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")) + if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" { + t.Fatalf("callback = %+v err=%v", callback, err) + } + device.BusinessAuthKeyID = [8]byte{7} + if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) { + t.Fatalf("other device callback err=%v", err) + } + if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) { + t.Fatalf("other device delete err=%v", err) + } + device.BusinessAuthKeyID = testDeviceKey + deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID) + if err != nil || !changed || !deleted.Deleted { + t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err) + } +} + +func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) { + f := newServiceFixture() + incoming := f.putIncoming(t, testDeviceKey, f.now) + device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession} + callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")) + if err != nil { + t.Fatal(err) + } + const queryID = int64(777) + created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{ + QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer, + MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now, + ExpiresAt: f.now.Add(domain.EphemeralReplyWindow), + }) + if err != nil || !created { + t.Fatalf("put callback action created=%v err=%v", created, err) + } + reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer, + CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"}, + }) + if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey { + t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err) + } + f.now = f.now.Add(domain.EphemeralReplyWindow) + if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer, + CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"}, + }); !errors.Is(err, domain.ErrEphemeralReplyExpired) { + t.Fatalf("expired callback action err=%v", err) + } +} + +func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) { + f := newServiceFixture() + f.channels.channel.Forum = true + incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42) + request := f.clientRequest() + request.Content.Message = "topic reply" + request.ReplyToEphemeralID = incoming.ID + reply, _, err := f.service.SendFromClient(context.Background(), request) + if err != nil || reply.TopMessageID != 42 { + t.Fatalf("topic reply=%+v err=%v", reply, err) + } + + f = newServiceFixture() + request = f.clientRequest() + request.TopMessageID = 42 + if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) { + t.Fatalf("non-forum topic err=%v", err) + } +} + +func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) { + f := newServiceFixture() + request := f.clientRequest() + request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private ")) + if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil { + t.Fatalf("4096 Unicode characters rejected: %v", err) + } + request.RandomID++ + request.Content.Message += "界" + if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) { + t.Fatalf("overlong Unicode text err=%v", err) + } +} + +func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) { + f := newServiceFixture() + textMessage := f.putIncoming(t, testDeviceKey, f.now) + if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID, + domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil { + t.Fatalf("text edit: %v", err) + } + if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID, + domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) { + t.Fatalf("caption edit on text err=%v", err) + } + + mediaMessage := f.putIncoming(t, testDeviceKey, f.now) + mediaContent := domain.EphemeralContent{ + Message: "caption", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}}, + } + mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now) + if err != nil { + t.Fatal(err) + } + if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID, + domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil { + t.Fatalf("media caption edit: %v", err) + } + if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID, + domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) { + t.Fatalf("text edit on media err=%v", err) + } +} + +func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) { + f := newServiceFixture() + builds := 0 + buildText := func(context.Context) (domain.EphemeralContent, error) { + builds++ + return domain.EphemeralContent{Message: "authorized"}, nil + } + request := domain.SendBotEphemeralRequest{ + BotUserID: testBotID, ReceiverUserID: testHumanID + 99, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel}, + } + if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil { + t.Fatal("unknown receiver was accepted") + } + if builds != 0 { + t.Fatalf("unauthorized send materialized content %d times", builds) + } + + f.channels.roles[testBotID] = domain.ChannelRoleAdmin + request.ReceiverUserID = testHumanID + if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh { + t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err) + } + if builds != 1 { + t.Fatalf("authorized send materialized content %d times", builds) + } + + incoming := f.putIncoming(t, testDeviceKey, f.now) + editBuilds := 0 + buildEdit := func(context.Context) (domain.EditEphemeralFields, error) { + editBuilds++ + return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil + } + if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID, + domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) { + t.Fatalf("unauthorized lazy edit err=%v", err) + } + if editBuilds != 0 { + t.Fatalf("unauthorized edit materialized content %d times", editBuilds) + } + if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID, + domain.EphemeralEditText, buildEdit); err != nil { + t.Fatalf("authorized lazy edit: %v", err) + } + if editBuilds != 1 { + t.Fatalf("authorized edit materialized content %d times", editBuilds) + } +} + +func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage { + return f.putIncomingInTopic(t, deviceKey, createdAt, 0) +} + +func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage { + t.Helper() + f.nextID++ + message := domain.EphemeralMessage{ + ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel}, + SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID), + TopMessageID: topMessageID, + Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupInline, + Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}}, + }}, + OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession}, + PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1, + CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention), + } + stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message) + if err != nil { + t.Fatal(err) + } + return stored +} diff --git a/internal/app/files/appearance_seed.go b/internal/app/files/appearance_seed.go index 6acbeedc..ca7b63c5 100644 --- a/internal/app/files/appearance_seed.go +++ b/internal/app/files/appearance_seed.go @@ -7,6 +7,7 @@ import ( "fmt" "hash" + "telesrv/internal/branding" "telesrv/internal/domain" "telesrv/internal/seed/appearance" ) @@ -253,7 +254,7 @@ func appearanceDocumentAttributes(in []appearance.DocumentAttribute) []domain.Do if attr.FileName != "" { out = append(out, domain.DocumentAttribute{ Kind: domain.DocAttrFilename, - FileName: attr.FileName, + FileName: branding.UserVisibleText(attr.FileName, ""), }) } } diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index b6f74436..2b40afcb 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -98,6 +98,41 @@ func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, e return s.media.GetPhoto(ctx, id) } +type photoBatchStore interface { + GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) +} + +// GetPhotos loads immutable photo metadata in caller order without requiring +// one storage round-trip per requested-peer response. PostgreSQL implements the +// optional batch primitive; lightweight stores retain a bounded fallback. +func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) { + if s == nil || s.media == nil || len(ids) == 0 { + return nil, nil + } + if batch, ok := s.media.(photoBatchStore); ok { + return batch.GetPhotos(ctx, ids) + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]domain.Photo, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + photo, found, err := s.media.GetPhoto(ctx, id) + if err != nil { + return nil, err + } + if found { + out = append(out, photo) + } + } + return out, nil +} + // GetDocument 按 id 返回已存储文档(贴纸 / 文件)。 func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { return s.media.GetDocument(ctx, id) diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go index a4d81cfc..8c5c7b27 100644 --- a/internal/app/langpack/service.go +++ b/internal/app/langpack/service.go @@ -7,6 +7,7 @@ import ( "golang.org/x/sync/singleflight" "golang.org/x/text/unicode/bidi" + "telesrv/internal/branding" "telesrv/internal/domain" "telesrv/internal/store" ) @@ -18,16 +19,34 @@ type Service struct { languageCache *languageListCache packLoads singleflight.Group languageLoads singleflight.Group + publicBaseURL string +} + +// Option configures user-visible language-pack projection. +type Option func(*Service) + +// WithPublicBaseURL replaces official public hosts embedded in upstream +// language-pack values with this deployment's public link root. +func WithPublicBaseURL(value string) Option { + return func(s *Service) { + if strings.TrimSpace(value) != "" { + s.publicBaseURL = value + } + } } // NewService 创建 langpack 服务。 -func NewService(packs store.LangPackStore) *Service { - return newServiceWithCacheLimits( +func NewService(packs store.LangPackStore, opts ...Option) *Service { + s := newServiceWithCacheLimits( packs, defaultLangPackCacheMaxBytes, defaultLangPackCacheMaxEntries, defaultLanguageListCacheMaxEntries, ) + for _, opt := range opts { + opt(s) + } + return s } func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service { @@ -35,6 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt packs: packs, packCache: newLangPackCache(maxBytes, maxEntries), languageCache: newLanguageListCache(languageEntries), + publicBaseURL: branding.DefaultPublicURL, } } @@ -135,7 +155,11 @@ func shouldOverlayWebA(langPack string) bool { func (s *Service) rawPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) { key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheRaw} return s.cachedPack(ctx, key, func() (domain.LangPack, error) { - return s.packs.GetPack(ctx, langPack, langCode, 0) + pack, err := s.packs.GetPack(ctx, langPack, langCode, 0) + if err != nil { + return domain.LangPack{}, err + } + return s.brandPack(pack), nil }) } @@ -218,6 +242,7 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai } for i := range languages { languages[i] = completeLanguageMetadata(langPack, languages[i]) + languages[i] = s.brandLanguage(languages[i]) } return cachedLanguagesLoadResult{ languages: languages, @@ -237,6 +262,27 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai } } +func (s *Service) brandPack(pack domain.LangPack) domain.LangPack { + for i := range pack.Strings { + item := &pack.Strings[i] + item.Value = branding.UserVisibleText(item.Value, s.publicBaseURL) + item.ZeroValue = branding.UserVisibleText(item.ZeroValue, s.publicBaseURL) + item.OneValue = branding.UserVisibleText(item.OneValue, s.publicBaseURL) + item.TwoValue = branding.UserVisibleText(item.TwoValue, s.publicBaseURL) + item.FewValue = branding.UserVisibleText(item.FewValue, s.publicBaseURL) + item.ManyValue = branding.UserVisibleText(item.ManyValue, s.publicBaseURL) + item.OtherValue = branding.UserVisibleText(item.OtherValue, s.publicBaseURL) + } + return pack +} + +func (s *Service) brandLanguage(lang domain.LangPackLanguage) domain.LangPackLanguage { + lang.Name = branding.UserVisibleText(lang.Name, s.publicBaseURL) + lang.NativeName = branding.UserVisibleText(lang.NativeName, s.publicBaseURL) + lang.TranslationsURL = branding.UserVisibleText(lang.TranslationsURL, s.publicBaseURL) + return lang +} + func (s *Service) flushCaches() { if s == nil { return diff --git a/internal/app/langpack/service_test.go b/internal/app/langpack/service_test.go index f40cddb6..fd4abea0 100644 --- a/internal/app/langpack/service_test.go +++ b/internal/app/langpack/service_test.go @@ -74,6 +74,66 @@ func TestServiceNormalizesWebARawLangCode(t *testing.T) { } } +func TestServiceRebrandsEveryLanguagePackProjection(t *testing.T) { + ctx := context.Background() + base := memory.NewLangPackStore() + seed := domain.LangPack{ + LangPack: "weba", + LangCode: "en", + Version: 7, + Strings: []domain.LangPackString{ + {Key: "AppName", Value: "Telegram", Pluralized: true, ZeroValue: "No Telegram accounts", OneValue: "One Telegram account", TwoValue: "Two Telegram accounts", FewValue: "Few Telegram accounts", ManyValue: "Many Telegram accounts", OtherValue: "Other Telegram accounts"}, + {Key: "TranslationLink", Value: "https://translations.telegram.org/en"}, + {Key: "RuntimeIdentifier", Value: "org.telegram.messenger"}, + }, + } + if err := base.UpsertPack(ctx, seed); err != nil { + t.Fatalf("seed langpack: %v", err) + } + storeWithMetadata := &metadataLangPackStore{ + LangPackStore: base, + languages: []domain.LangPackLanguage{{ + LangPack: "weba", + LangCode: "en", + Name: "Telegram English", + NativeName: "Telegram English", + TranslationsURL: "https://translations.telegram.org/en", + }}, + } + svc := NewService(storeWithMetadata, WithPublicBaseURL("https://chat.example/root/")) + + for name, load := range map[string]func() (domain.LangPack, error){ + "full": func() (domain.LangPack, error) { return svc.GetLangPack(ctx, "weba", "en") }, + "difference": func() (domain.LangPack, error) { return svc.GetDifference(ctx, "weba", "en", 1) }, + "keys": func() (domain.LangPack, error) { + return svc.GetStrings(ctx, "weba", "en", []string{"AppName", "TranslationLink", "RuntimeIdentifier"}) + }, + } { + pack, err := load() + if err != nil { + t.Fatalf("%s projection: %v", name, err) + } + appName := findLangPackString(pack.Strings, "AppName") + if appName == nil || appName.Value != "Telesrv" || appName.ZeroValue != "No Telesrv accounts" || appName.OneValue != "One Telesrv account" || appName.TwoValue != "Two Telesrv accounts" || appName.FewValue != "Few Telesrv accounts" || appName.ManyValue != "Many Telesrv accounts" || appName.OtherValue != "Other Telesrv accounts" { + t.Fatalf("%s AppName = %+v, want all value forms rebranded", name, appName) + } + if got := stringValue(pack.Strings, "TranslationLink"); got != "https://chat.example/root/en" { + t.Fatalf("%s TranslationLink = %q", name, got) + } + if got := stringValue(pack.Strings, "RuntimeIdentifier"); got != "org.telegram.messenger" { + t.Fatalf("%s RuntimeIdentifier = %q, want protocol identifier unchanged", name, got) + } + } + + languages, err := svc.ListLanguages(ctx, "weba") + if err != nil { + t.Fatalf("list languages: %v", err) + } + if len(languages) != 1 || languages[0].Name != "Telesrv English" || languages[0].NativeName != "Telesrv English" || languages[0].TranslationsURL != "https://chat.example/root/en" { + t.Fatalf("languages = %+v, want branded metadata", languages) + } +} + func TestListLanguagesUsesSeededPacks(t *testing.T) { ctx := context.Background() packs := memory.NewLangPackStore() @@ -286,6 +346,15 @@ type countingLangPackStore struct { listLanguages int } +type metadataLangPackStore struct { + store.LangPackStore + languages []domain.LangPackLanguage +} + +func (s *metadataLangPackStore) ListLanguages(context.Context, string) ([]domain.LangPackLanguage, error) { + return append([]domain.LangPackLanguage(nil), s.languages...), nil +} + func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { s.mu.Lock() s.getPack++ @@ -334,3 +403,12 @@ func stringValue(strings []domain.LangPackString, key string) string { } return "" } + +func findLangPackString(strings []domain.LangPackString, key string) *domain.LangPackString { + for i := range strings { + if strings[i].Key == key { + return &strings[i] + } + } + return nil +} diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 478b042c..8099b7b5 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain. if req.SenderUserID != userID { return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid } + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.SendPrivateTextResult{}, err + } if req.RandomID != 0 && !req.IdempotencyPreflighted { fingerprint, err := store.PrivateSendFingerprint(req) if err != nil { @@ -263,6 +266,32 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom return s.projectMessageUsers(ctx, userID, list) } +type messageByUIDStore interface { + GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) +} + +// GetMessageByUID translates a shared private message id into one owner's exact box row. +// It is intentionally an optional capability so lightweight MessageStore test doubles that +// never exercise callback translation do not need a meaningless implementation. +func (s *Service) GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) { + if s == nil || userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + provider, ok := s.messages.(messageByUIDStore) + if !ok { + return domain.Message{}, false, nil + } + msg, found, err := provider.GetByUID(ctx, userID, uid) + if err != nil || !found { + return domain.Message{}, found, err + } + list, err := s.projectMessageUsers(ctx, userID, domain.MessageList{Messages: []domain.Message{msg}}) + if err != nil || len(list.Messages) != 1 { + return domain.Message{}, false, err + } + return list.Messages[0], true, nil +} + // GetHistory 返回当前账号某个 peer 的历史消息。 func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { return s.list(ctx, userID, filter) @@ -406,6 +435,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit if req.OwnerUserID == 0 { req.OwnerUserID = userID } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditMessageResult{OwnerUserID: userID}, err + } + } return s.messages.EditMessage(ctx, req) } diff --git a/internal/app/passkey/service.go b/internal/app/passkey/service.go index dee752c7..bb25262a 100644 --- a/internal/app/passkey/service.go +++ b/internal/app/passkey/service.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "telesrv/internal/branding" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/webauthn" @@ -40,7 +41,7 @@ type Option func(*Service) func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } } // WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin -//(服务端通常不预知 Android apk-key-hash origin)。 +// (服务端通常不预知 Android apk-key-hash origin)。 func WithAllowedOrigins(origins []string) Option { return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) } } @@ -70,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore creds: creds, challenges: challenges, rpID: rpID, - rpName: "Telegram", + rpName: branding.ProductName, dcID: dcID, challengeTTL: defaultChallengeTTL, now: time.Now, diff --git a/internal/app/stargifts/animation.go b/internal/app/stargifts/animation.go index b8d23ea4..0df97e9e 100644 --- a/internal/app/stargifts/animation.go +++ b/internal/app/stargifts/animation.go @@ -21,7 +21,18 @@ func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGif return prepareAnimation(fileName, data) } +// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official +// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary +// operator uploads continue through PrepareAnimation and reject expressions. +func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) { + return prepareAnimationWithPolicy(fileName, data, true) +} + func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) { + return prepareAnimationWithPolicy(fileName, data, false) +} + +func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) { fileName = strings.TrimSpace(filepath.Base(fileName)) ext := strings.ToLower(filepath.Ext(fileName)) format := domain.StarGiftAnimationLottie @@ -46,7 +57,7 @@ func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, e rawJSON = data } - normalized, meta, err := normalizeAndValidateLottie(rawJSON) + normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions) if err != nil { return domain.StarGiftAnimation{}, err } @@ -80,7 +91,7 @@ type lottieMetadata struct { Assets []json.RawMessage `json:"assets"` } -func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) { +func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) { data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) { return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid @@ -94,7 +105,7 @@ func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) { if _, ok := root.(map[string]any); !ok { return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid } - if containsLottieExpression(root) { + if !allowExpressions && containsLottieExpression(root) { return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid) } var meta lottieMetadata diff --git a/internal/app/stargifts/animation_test.go b/internal/app/stargifts/animation_test.go index c21794ca..3e56141a 100644 --- a/internal/app/stargifts/animation_test.go +++ b/internal/app/stargifts/animation_test.go @@ -68,7 +68,7 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) { t.Fatal(err) } first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ - Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation, + Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation, }) if err != nil { t.Fatalf("create first: %v", err) @@ -84,10 +84,86 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) { t.Fatalf("current=%+v found=%v", current, found) } historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID) - if !found || historical.Stars != 50 || historical.Title != "First" { + if !found || historical.Stars != 50 || historical.Title != "Telesrv Pin" { t.Fatalf("historical=%+v found=%v", historical, found) } if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) { t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err) } } + +func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(t *testing.T) { + ctx := context.Background() + store := memory.NewStarGiftStore() + svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2) + animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie)) + if err != nil { + t.Fatal(err) + } + hash := make([]byte, sha256.Size) + _, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{ + Catalog: domain.StarGiftCatalogWrite{ + Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation, + OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`), + }, + Collectible: &domain.StarGiftCollectibleWrite{ + OfficialGiftID: 11, SourceManifestSHA256: hash, + }, + }) + if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err) + } +} + +func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testing.T) { + ctx := context.Background() + store := memory.NewStarGiftStore() + svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2) + animation, err := svc.PrepareOfficialAnimation("official.json", []byte(validGiftLottie)) + if err != nil { + t.Fatal(err) + } + manifestSHA := make([]byte, sha256.Size) + result, err := svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{ + Catalog: domain.StarGiftCatalogWrite{ + Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, Animation: animation, + Actor: "test", CommandID: "official-catalog", OfficialGiftID: 10, + SourceManifestSHA256: manifestSHA, OfficialSourceJSON: []byte(`{"id":10}`), + }, + Collectible: &domain.StarGiftCollectibleWrite{ + UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10", + Models: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, + RarityPermille: 1000, Animation: &animation, + }}, + Patterns: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, + RarityPermille: 1000, Animation: &animation, + }}, + Backdrops: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", RarityKind: domain.StarGiftRarityPermille, + RarityPermille: 1000, + }}, + Actor: "test", CommandID: "official-pool", OfficialGiftID: 10, + SourceManifestSHA256: manifestSHA, + }, + }) + if err != nil { + t.Fatalf("create official collectible bundle: %v", err) + } + if result.Collectible == nil || len(result.Collectible.Models) != 1 || len(result.Collectible.Patterns) != 1 { + t.Fatalf("collectible result = %+v", result.Collectible) + } + model := result.Collectible.Models[0].Document + pattern := result.Collectible.Patterns[0].Document + if model == nil || !model.IsSticker() || model.IsCustomEmoji() { + t.Fatalf("model document = %+v, want ordinary sticker", model) + } + if pattern == nil || pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 || + pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 { + t.Fatalf("pattern document = %+v, want text-color custom emoji with inline path", pattern) + } + if !pattern.Attributes[1].TextColor { + t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1]) + } +} diff --git a/internal/app/stargifts/collectible_emoji_status_test.go b/internal/app/stargifts/collectible_emoji_status_test.go new file mode 100644 index 00000000..06f64731 --- /dev/null +++ b/internal/app/stargifts/collectible_emoji_status_test.go @@ -0,0 +1,34 @@ +package stargifts + +import ( + "bytes" + "testing" + + "telesrv/internal/domain" +) + +func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) { + pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern) + if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor { + t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern) + } + model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel) + if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor { + t.Fatalf("model attributes = %+v, want ordinary sticker", model) + } +} + +func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) { + pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern) + if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath || + pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) { + t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern) + } + pattern[0].Bytes[0] ^= 0xff + if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) { + t.Fatal("collectibleDocumentThumbs returned shared mutable bytes") + } + if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 { + t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model) + } +} diff --git a/internal/app/stargifts/local_withdrawal.go b/internal/app/stargifts/local_withdrawal.go new file mode 100644 index 00000000..dcbc9896 --- /dev/null +++ b/internal/app/stargifts/local_withdrawal.go @@ -0,0 +1,50 @@ +package stargifts + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/url" + "strings" + "time" +) + +const localWithdrawalTTL = 15 * time.Minute + +// LocalWithdrawalProvider implements the TON/export UX entirely inside +// telesrv. It mints an unguessable, short-lived bearer URL; no external +// blockchain, Fragment endpoint, wallet or network RPC is contacted. +type LocalWithdrawalProvider struct { + publicBaseURL string +} + +func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) { + publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + parsed, err := url.Parse(publicBaseURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, fmt.Errorf("invalid local star gift withdrawal base URL") + } + return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil +} + +func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" } + +func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) { + if p == nil || p.publicBaseURL == "" { + return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured") + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + return StarGiftWithdrawalProviderResult{ + RequestID: token, + URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token), + ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()), + }, nil +} + +var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil) diff --git a/internal/app/stargifts/local_withdrawal_test.go b/internal/app/stargifts/local_withdrawal_test.go new file mode 100644 index 00000000..059c8831 --- /dev/null +++ b/internal/app/stargifts/local_withdrawal_test.go @@ -0,0 +1,34 @@ +package stargifts + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) { + for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} { + if _, err := NewLocalWithdrawalProvider(invalid); err == nil { + t.Fatalf("invalid withdrawal base URL %q accepted", invalid) + } + } + provider, err := NewLocalWithdrawalProvider("https://example.test/base/") + if err != nil { + t.Fatal(err) + } + before := time.Now() + result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{}) + if err != nil { + t.Fatal(err) + } + if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 || + result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID || + strings.ContainsAny(result.RequestID, "+/=") { + t.Fatalf("local withdrawal result = %+v", result) + } + expires := time.Unix(int64(result.ExpiresAt), 0) + if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) { + t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires) + } +} diff --git a/internal/app/stargifts/official_snapshot_test.go b/internal/app/stargifts/official_snapshot_test.go new file mode 100644 index 00000000..85f52835 --- /dev/null +++ b/internal/app/stargifts/official_snapshot_test.go @@ -0,0 +1,56 @@ +package stargifts_test + +import ( + "context" + "os" + "testing" + + "telesrv/internal/app/stargifts" + "telesrv/internal/officialgifts" +) + +// This opt-in test is run by the official import audit. It validates every distinct base, +// model and pattern document with the trusted official animation policy, including the +// small set of Telegram-authored expression animations. +func TestConfiguredOfficialSnapshotAnimations(t *testing.T) { + root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR") + if root == "" { + t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set") + } + catalog := officialgifts.New(root) + items, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + service := &stargifts.Service{} + seen := map[int64]struct{}{} + validate := func(document officialgifts.Document) { + t.Helper() + if _, ok := seen[document.ID]; ok { + return + } + seen[document.ID] = struct{}{} + if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil { + t.Fatalf("document %d (%s): %v", document.ID, document.Path, err) + } + } + for _, item := range items { + bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0) + if err != nil { + t.Fatalf("gift %d: %v", item.ID, err) + } + validate(bundle.BaseDocument) + if bundle.Collectible == nil { + continue + } + for _, model := range bundle.Collectible.Models { + validate(model.Document) + } + for _, pattern := range bundle.Collectible.Patterns { + validate(pattern.Document) + } + } + if len(seen) != 8333 { + t.Fatalf("validated %d documents, want 8333", len(seen)) + } +} diff --git a/internal/app/stargifts/service.go b/internal/app/stargifts/service.go index 43309748..baaecd9b 100644 --- a/internal/app/stargifts/service.go +++ b/internal/app/stargifts/service.go @@ -2,14 +2,18 @@ package stargifts import ( + "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/binary" + "encoding/json" "fmt" "strings" "sync" "time" + "telesrv/internal/branding" "telesrv/internal/domain" "telesrv/internal/store" ) @@ -22,26 +26,65 @@ type BlobBackend interface { } type Service struct { - store store.StarGiftStore - upgrades store.StarGiftUpgradeStore - blobs BlobBackend - dc int + store store.StarGiftStore + upgrades store.StarGiftUpgradeStore + lifecycle store.StarGiftLifecycleStore + withdrawal StarGiftWithdrawalProvider + blobs BlobBackend + dc int mu sync.RWMutex built bool gifts []domain.StarGift byID map[int64]domain.StarGift hash int + + formMu sync.Mutex + forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm } +type starGiftPurchaseFormKey struct { + buyerUserID int64 + formID int64 +} + +// AtomicPurchaseConfigured reports whether the production aggregate +// coordinator is installed. It lets the RPC package keep its isolated memory +// test adapter without silently downgrading PostgreSQL deployments. +func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil } + type Option func(*Service) func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option { return func(service *Service) { service.upgrades = upgrades } } +func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option { + return func(service *Service) { service.lifecycle = lifecycle } +} + +type StarGiftWithdrawalProvider interface { + Name() string + CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) +} + +type StarGiftWithdrawalProviderRequest struct { + UserID int64 + Gift domain.UniqueStarGift +} + +type StarGiftWithdrawalProviderResult struct { + RequestID string + URL string + ExpiresAt int +} + +func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option { + return func(service *Service) { service.withdrawal = provider } +} + func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service { - service := &Service{store: st, blobs: blobs, dc: dc} + service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)} for _, opt := range opts { opt(service) } @@ -131,27 +174,39 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi if s == nil || s.store == nil || s.blobs == nil { return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured") } - write.Title = strings.TrimSpace(write.Title) + write.Title = branding.UserVisibleText(strings.TrimSpace(write.Title), "") if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars || write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 || len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes { return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid } + if err := s.materializeCatalogWrite(ctx, &write); err != nil { + return domain.StarGiftCatalogEntry{}, err + } + entry, err := s.store.CreateCatalogRevision(ctx, write) + if err != nil { + return domain.StarGiftCatalogEntry{}, err + } + s.InvalidateStarGiftCatalog() + return entry, nil +} + +func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error { objectKey, err := s.blobs.Put(ctx, write.Animation.TGS) if err != nil { - return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err) + return fmt.Errorf("store star gift animation: %w", err) } documentID, err := randomPositiveInt64() if err != nil { - return domain.StarGiftCatalogEntry{}, err + return err } accessHash, err := randomPositiveInt64() if err != nil { - return domain.StarGiftCatalogEntry{}, err + return err } fileReference := make([]byte, 16) if _, err := rand.Read(fileReference); err != nil { - return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err) + return fmt.Errorf("generate star gift file reference: %w", err) } write.Document = domain.Document{ ID: documentID, @@ -175,12 +230,70 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi SHA256: append([]byte(nil), write.Animation.SHA256...), MimeType: "application/x-tgsticker", } - entry, err := s.store.CreateCatalogRevision(ctx, write) - if err != nil { - return domain.StarGiftCatalogEntry{}, err + return nil +} + +// CreateCatalogBundle materializes every verified asset before publishing both active +// revision pointers in one store transaction. Blob writes are content-addressed and may be +// safely orphaned for later GC if the database transaction fails. +func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + if s == nil || s.store == nil || s.blobs == nil { + return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured") } - s.InvalidateStarGiftCatalog() - return entry, nil + write.Catalog.Title = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "") + write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "") + if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars || + write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 || + len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + var officialSource map[string]any + if write.Catalog.OfficialGiftID < 0 { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 || + json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid + } + if write.Collectible != nil { + write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix)) + brandCollectibleAttributes(write.Collectible.Models) + brandCollectibleAttributes(write.Collectible.Patterns) + brandCollectibleAttributes(write.Collectible.Backdrops) + if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID || + !bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) { + return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid + } + validation := *write.Collectible + if validation.GiftID == 0 { + validation.GiftID = write.Catalog.GiftID + if validation.GiftID == 0 { + validation.GiftID = 1 + } + } + if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + if write.Collectible != nil { + if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + result, err := s.store.CreateCatalogBundle(ctx, write) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err } func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) { @@ -207,6 +320,9 @@ func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.S if s == nil || s.store == nil { return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured") } + brandCollectibleAttributes(write.Models) + brandCollectibleAttributes(write.Patterns) + brandCollectibleAttributes(write.Backdrops) revision, err := s.store.PublishCollectibleRevision(ctx, write) if err == nil { s.InvalidateStarGiftCatalog() @@ -222,58 +338,109 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured") } write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix)) + brandCollectibleAttributes(write.Models) + brandCollectibleAttributes(write.Patterns) + brandCollectibleAttributes(write.Backdrops) if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil { return domain.StarGiftCollectibleRevision{}, err } - materialize := func(attributes []domain.StarGiftCollectibleAttribute) error { - for i := range attributes { - animation := attributes[i].Animation - if animation == nil { - return domain.ErrStarGiftCollectibleInvalid - } - objectKey, err := s.blobs.Put(ctx, animation.TGS) - if err != nil { - return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err) - } - documentID, err := randomPositiveInt64() - if err != nil { - return err - } - accessHash, err := randomPositiveInt64() - if err != nil { - return err - } - fileReference := make([]byte, 16) - if _, err := rand.Read(fileReference); err != nil { - return fmt.Errorf("generate collectible file reference: %w", err) - } - attributes[i].Document = &domain.Document{ - ID: documentID, AccessHash: accessHash, FileReference: fileReference, - Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker", - Size: int64(len(animation.TGS)), DCID: s.dc, - Attributes: []domain.DocumentAttribute{ - {Kind: domain.DocAttrImageSize, W: 512, H: 512}, - {Kind: domain.DocAttrSticker, Alt: "🎁"}, - {Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"}, - }, - } - attributes[i].Blob = &domain.FileBlob{ - LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()), - ObjectKey: objectKey, Size: int64(len(animation.TGS)), - SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker", - } - } - return nil - } - if err := materialize(write.Models); err != nil { + if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil { return domain.StarGiftCollectibleRevision{}, err } - if err := materialize(write.Patterns); err != nil { + if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil { return domain.StarGiftCollectibleRevision{}, err } return s.PublishCollectibleRevision(ctx, write) } +func brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) { + for i := range attributes { + attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "") + } +} + +func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error { + for i := range attributes { + animation := attributes[i].Animation + if animation == nil { + return domain.ErrStarGiftCollectibleInvalid + } + objectKey, err := s.blobs.Put(ctx, animation.TGS) + if err != nil { + return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err) + } + documentID, err := randomPositiveInt64() + if err != nil { + return err + } + accessHash, err := randomPositiveInt64() + if err != nil { + return err + } + fileReference := make([]byte, 16) + if _, err := rand.Read(fileReference); err != nil { + return fmt.Errorf("generate collectible file reference: %w", err) + } + attributes[i].Document = &domain.Document{ + ID: documentID, AccessHash: accessHash, FileReference: fileReference, + Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker", + Size: int64(len(animation.TGS)), DCID: s.dc, + Attributes: collectibleDocumentAttributes(attributes[i].Kind), + Thumbs: collectibleDocumentThumbs(attributes[i].Kind), + } + attributes[i].Blob = &domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, Size: int64(len(animation.TGS)), + SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker", + } + } + return nil +} + +// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder. +// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an +// animated sticker only when document.thumbs is non-empty. The placeholder is +// not used as the rendered collectible pattern: after classification Android +// downloads and decodes the document's full TGS first frame. Keeping the +// placeholder inline avoids introducing a second downloadable blob and matches +// the shape used by official animated-sticker documents. +var collectiblePatternPathThumb = []byte{ + 0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e, + 0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c, + 0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3, + 0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c, + 0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90, + 0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86, + 0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79, + 0x87, 0x03, 0x47, 0x06, 0x87, 0x03, +} + +func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize { + if kind != domain.StarGiftCollectiblePattern { + return nil + } + return []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindPath, + Type: "j", + Bytes: append([]byte(nil), collectiblePatternPathThumb...), + }} +} + +func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute { + renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"} + if kind == domain.StarGiftCollectiblePattern { + // DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the + // pattern is a text-color custom emoji. Without this the gradient is + // visible but the collectible pattern is rendered with its raw fill. + renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true} + } + return []domain.DocumentAttribute{ + {Kind: domain.DocAttrImageSize, W: 512, H: 512}, + renderAttribute, + {Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"}, + } +} + func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) { if s == nil || s.store == nil || giftID <= 0 { return domain.StarGiftUpgradePreview{}, false, nil @@ -324,6 +491,14 @@ func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[i return s.store.UniqueByIDs(ctx, uniqueGiftIDs) } +func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if s == nil || s.store == nil || owner.ID <= 0 || + (owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit)) +} + func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { if s == nil || s.upgrades == nil { return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured") @@ -335,6 +510,338 @@ func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest return result, err } +func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) { + if s == nil || s.upgrades == nil { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey) +} + +func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable + } + result, err := s.lifecycle.PurchaseStarGift(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the +// intent so server restarts cannot turn a valid checkout into an unbound +// payment. The bounded in-memory branch exists only for isolated RPC tests. +func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) { + if !validPurchaseForm(form) { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid + } + if s != nil && s.lifecycle != nil { + return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form) + } + if s == nil { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable + } + s.formMu.Lock() + defer s.formMu.Unlock() + for key, existing := range s.forms { + if existing.ExpiresAt < form.IssuedAt { + delete(s.forms, key) + } + } + for attempt := 0; attempt < 8; attempt++ { + formID, err := randomPositiveInt64() + if err != nil { + return domain.StarGiftPurchaseForm{}, err + } + key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID} + if _, exists := s.forms[key]; exists { + continue + } + form.FormID = formID + s.forms[key] = form + return form, nil + } + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable +} + +// ValidatePurchaseForm is a read-only preflight used for precise RPC errors. +// The PostgreSQL purchase transaction repeats this validation while holding a +// row lock; callers must not treat this preflight as the atomicity boundary. +func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error { + if s != nil && s.lifecycle != nil { + return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req) + } + if s == nil || req.FormID == 0 { + return domain.ErrStarGiftFormExpired + } + s.formMu.Lock() + defer s.formMu.Unlock() + form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}] + if !ok || form.ExpiresAt < req.Date { + return domain.ErrStarGiftFormExpired + } + return validatePurchaseFormIntent(form, req) +} + +func validPurchaseForm(form domain.StarGiftPurchaseForm) bool { + return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 && + (form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) && + form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 && + form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128 +} + +func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error { + if form.BuyerUserID != req.BuyerUserID || form.To != req.To || form.GiftID != req.GiftID || + form.IncludeUpgrade != req.IncludeUpgrade || form.HideName != req.HideName || form.Message != req.Message { + return domain.ErrStarGiftFormPurposeInvalid + } + if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars { + return domain.ErrStarGiftFormAmountMismatch + } + return nil +} + +func (s *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + return s.lifecycle.ListResaleStarGifts(ctx, filter) +} + +func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable + } + return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID) +} + +func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) { + if s == nil || s.lifecycle == nil { + return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable + } + result, err := s.lifecycle.SetStarGiftListing(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + return s.lifecycle.TransferStarGift(ctx, req) +} + +func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req) + if err == nil { + s.InvalidateStarGiftCatalog() + } + return result, err +} + +func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + return s.lifecycle.SendStarGiftOffer(ctx, req) +} + +func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + return s.lifecycle.ResolveStarGiftOffer(ctx, req) +} + +func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) { + if s == nil || s.lifecycle == nil { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit) +} + +func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + return s.lifecycle.CraftStarGift(ctx, req) +} + +func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now) +} + +func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) { + if s == nil || s.lifecycle == nil { + return nil, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now) +} + +func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) { + if s == nil || s.lifecycle == nil { + return nil, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID) +} + +func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable + } + return s.lifecycle.BidStarGiftAuction(ctx, req) +} + +func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) { + if s == nil || s.lifecycle == nil { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash) +} + +func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.PrepayStarGiftUpgrade(ctx, req) +} + +func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + return s.lifecycle.DropStarGiftOriginalDetails(ctx, req) +} + +func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error { + if s == nil || s.lifecycle == nil { + return domain.ErrStarGiftUnavailable + } + return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled) +} + +func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) { + if s == nil || s.lifecycle == nil || s.withdrawal == nil { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + saved, found, err := s.store.GetByRef(ctx, req.Ref) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date { + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable + } + unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found || unique.Burned || unique.Owner != saved.Owner { + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable + } + providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique}) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + return recorded, nil +} + +func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftWithdrawal{}, false, nil + } + return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID) +} + +func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date) +} + +func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.TonBalance(ctx, userID) +} + +func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.TonTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.TonTransactions(ctx, userID, offset, limit) +} + +func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.ChannelStarsBalance(ctx, channelID) +} + +func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.StarsTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit) +} + +func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) { + if s == nil || s.lifecycle == nil { + return 0, nil + } + return s.lifecycle.ChannelTonBalance(ctx, channelID) +} + +func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if s == nil || s.lifecycle == nil { + return domain.TonTransactionPage{}, nil + } + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit) +} + +func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error { + if s == nil || s.lifecycle == nil { + return nil + } + return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit) +} + func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) { return s.store.ListCollections(ctx, owner) } @@ -360,6 +867,17 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs } func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) { + if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil { + if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil { + return 0, err + } else if ok && revision.Published && revision.Issued < revision.SupplyTotal { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err) + } + gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:]) + } + } return s.store.Create(ctx, gift) } @@ -396,10 +914,20 @@ func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, return s.store.SetUnsaved(ctx, ref, unsaved) } +// Convert keeps the in-memory/catalog store primitive available to isolated +// tests and non-production adapters. RPC production paths must use +// ConvertAggregate so balance credit and terminal state cannot split. func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { return s.store.MarkConverted(ctx, ref) } +func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) { + if s == nil || s.lifecycle == nil { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable + } + return s.lifecycle.ConvertStarGift(ctx, req) +} + func randomPositiveInt64() (int64, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go index 1cd2036a..bbae8f17 100644 --- a/internal/app/updates/service.go +++ b/internal/app/updates/service.go @@ -593,6 +593,20 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt }, true, excludeSessionID) } +// RecordUserEmojiStatus durably synchronizes an absolute emoji-status snapshot +// to the account's other sessions and offline difference stream. +func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if !status.Valid() { + return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid + } + return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + EmojiStatus: status, + PtsCount: 1, + }, true, excludeSessionID) +} + // RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对 // 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts // aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。 diff --git a/internal/app/updates/service_test.go b/internal/app/updates/service_test.go index 55a8b8dd..9d124e43 100644 --- a/internal/app/updates/service_test.go +++ b/internal/app/updates/service_test.go @@ -219,6 +219,35 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { } } +func TestRecordCollectibleEmojiStatusFeedsDifference(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{3, 1} + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + ownerUserID := int64(1000000001) + status := domain.UserEmojiStatus{ + DocumentID: 71, + Collectible: domain.EmojiStatusCollectible{ + CollectibleID: 91, DocumentID: 71, Title: "Gift", Slug: "Gift-1", + PatternDocumentID: 72, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, + }, + } + event, state, err := svc.RecordUserEmojiStatus(ctx, authKeyID, ownerUserID, status, authKeyID, 42) + if err != nil { + t.Fatalf("RecordUserEmojiStatus: %v", err) + } + if event.Type != domain.UpdateEventUserEmojiStatus || event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() { + t.Fatalf("event/state = %+v / %+v", event, state) + } + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 1 || diff.Events[0].EmojiStatus != status || diff.Events[0].Peer.ID != ownerUserID { + t.Fatalf("difference = %+v, want exact collectible snapshot", diff) + } +} + func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { ctx := context.Background() authKeyID := [8]byte{4} diff --git a/internal/app/userprojection/deleted_test.go b/internal/app/userprojection/deleted_test.go new file mode 100644 index 00000000..93c85e0e --- /dev/null +++ b/internal/app/userprojection/deleted_test.go @@ -0,0 +1,19 @@ +package userprojection + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) { + in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true} + got, err := New().One(context.Background(), 7, in) + if err != nil { + t.Fatal(err) + } + if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact { + t.Fatalf("deleted projection leaked PII: %+v", got) + } +} diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 054b6fe3..60fe7d19 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -83,6 +83,7 @@ func New(opts ...Option) *Projector { // ForViewer applies both current profile photos and owner-specific contact view. func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) { + users = sanitizeDeletedUsers(users) if p == nil { return users, nil } @@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use // (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。 // 调用方传入的 users 不被修改(内部复制)。 func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) { + users = sanitizeDeletedUsers(users) out := make(map[int64][]domain.User, len(viewerUserIDs)) if p == nil || len(users) == 0 { for _, v := range viewerUserIDs { @@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users if u.ID == 0 { continue } + if u.Deleted { + projected[i] = u.DeletedTombstone() + continue + } if pj, ok := cache[u.ID]; ok { projected[i] = pj continue @@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 { // WithProfilePhotos enriches users with their current avatar from profile photo storage. // The lookup is best-effort: a storage error keeps the original user list. func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User { + users = sanitizeDeletedUsers(users) if photos == nil || len(users) == 0 { return users } ids := make([]int64, 0, len(users)) seen := make(map[int64]struct{}, len(users)) for _, u := range users { - if u.ID == 0 { + if u.ID == 0 || u.Deleted { continue } if _, ok := seen[u.ID]; ok { @@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [ // In particular, phone is visible for self and contacts; non-contacts should not // receive a phone field because TDesktop will prefer it over the public name. func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) { + users = sanitizeDeletedUsers(users) if contacts == nil || viewerUserID == 0 || len(users) == 0 { return users, nil } @@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in cache := make(map[int64]domain.User, len(users)) for i := range out { u := out[i] - if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { + if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { continue } if projected, ok := cache[u.ID]; ok { @@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi } out := make([]domain.User, len(users)) copy(out, users) + out = sanitizeDeletedUsers(out) ids := uniqueUserIDs(out) var ( profileRefs = map[int64]domain.ProfilePhotoRef{} @@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi if u.ID == 0 { continue } + if u.Deleted { + out[i] = u.DeletedTombstone() + continue + } if projected, ok := cache[u.ID]; ok { out[i] = projected continue @@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi ids := make([]int64, 0, len(users)) seen := make(map[int64]struct{}, len(users)) for _, u := range users { - if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { + if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { continue } if _, ok := seen[u.ID]; ok { @@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi } func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) { + if user.Deleted { + return user.DeletedTombstone(), nil + } contact, found, err := contacts.Get(ctx, viewerUserID, user.ID) if err != nil { return domain.User{}, err @@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 { seen := make(map[int64]struct{}, len(users)) ids := make([]int64, 0, len(users)) for _, user := range users { - if user.ID == 0 { + if user.ID == 0 || user.Deleted { continue } if _, ok := seen[user.ID]; ok { @@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 { } func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User { + if user.Deleted { + return user.DeletedTombstone() + } if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) { return user } @@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m } func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User { + if user.Deleted { + return user.DeletedTombstone() + } if !found { user.Phone = "" user.Contact = false @@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool } func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { + if user.Deleted { + return user.DeletedTombstone(), nil + } if privacy == nil { return user, nil } @@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) { user.PhotoPersonal = false user.PhotoHasVideo = false } + +func sanitizeDeletedUsers(users []domain.User) []domain.User { + var out []domain.User + for i, user := range users { + if !user.Deleted { + continue + } + if out == nil { + out = make([]domain.User, len(users)) + copy(out, users) + } + out[i] = user.DeletedTombstone() + } + if out != nil { + return out + } + return users +} diff --git a/internal/app/users/premium_test.go b/internal/app/users/premium_test.go index 4818b6ac..0dc88f91 100644 --- a/internal/app/users/premium_test.go +++ b/internal/app/users/premium_test.go @@ -107,7 +107,7 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) { svc := NewService(store) // 非会员设置被拒(PREMIUM_ACCOUNT_REQUIRED)。 - if _, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0); !errors.Is(err, domain.ErrPremiumRequired) { + if _, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}); !errors.Is(err, domain.ErrPremiumRequired) { t.Fatalf("non-premium set err = %v, want ErrPremiumRequired", err) } @@ -115,14 +115,14 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) { if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { t.Fatalf("grant: %v", err) } - set, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0) + set, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}) if err != nil || set.EmojiStatusDocumentID != 42 { t.Fatalf("premium set = %+v err %v, want document 42", set, err) } if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil { t.Fatalf("downgrade: %v", err) } - cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, 0, 0) + cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{}) if err != nil || cleared.EmojiStatusDocumentID != 0 { t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err) } diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 1e2f69b1..fa7bed43 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int) return users, nil } -// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;documentID=0 清除)。 +// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;零值清除)。 // 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。 -func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { - self, err := s.loadSelf(ctx, userID) +func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + self, err := s.validateEmojiStatusUpdate(ctx, userID, status) if err != nil { return domain.User{}, err } - if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) { - return domain.User{}, domain.ErrPremiumRequired - } - u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until) + u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status) if err != nil { return domain.User{}, err } @@ -393,6 +390,52 @@ func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentI return s.projectOne(ctx, self.ID, u) } +// UpdateEmojiStatusWithEvent uses the store's aggregate transaction when it +// is available. The bool reports whether the returned event was durably +// appended with dispatch; lightweight memory/test wiring falls back to the +// ordinary state write and lets the RPC's Updates service append the event. +func (s *Service) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) { + self, err := s.validateEmojiStatusUpdate(ctx, userID, status) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, false, err + } + writer, ok := s.users.(store.UserEmojiStatusEventStore) + if !ok { + u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status) + if err == nil { + s.refreshCachedUsers(ctx, u) + } + return u, domain.UpdateEvent{}, false, err + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: self.ID}, + EmojiStatus: status, + Date: date, + PtsCount: 1, + } + u, event, err := writer.UpdateEmojiStatusWithEvent(ctx, self.ID, status, event, excludeAuthKeyID, excludeSessionID) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, false, err + } + s.refreshCachedUsers(ctx, u) + return u, event, true, nil +} + +func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !status.Valid() { + return domain.User{}, domain.ErrStarGiftCollectibleInvalid + } + if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) { + return domain.User{}, domain.ErrPremiumRequired + } + return self, nil +} + // UpdateBirthday 设置/清除用户生日(account.updateBirthday)。零值 Birthday 表示清除。 func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { self, err := s.loadSelf(ctx, userID) @@ -513,7 +556,7 @@ func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]do if s.cache != nil { if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 { for id, u := range cached { - if u.ID != 0 { + if u.ID != 0 && u.EmojiStatusCollectible.Empty() { loaded[id] = u } } @@ -561,7 +604,18 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) { if s.cache == nil || len(users) == 0 { return } - _ = s.cache.PutMany(ctx, users) + cacheable := make([]domain.User, 0, len(users)) + for _, user := range users { + // Collectible ownership may change inside the star-gift aggregate. Keep + // these uncommon users on the authoritative store path so the database + // lifecycle trigger can never be masked by a stale base-user cache entry. + if user.ID != 0 && user.EmojiStatusCollectible.Empty() { + cacheable = append(cacheable, user) + } + } + if len(cacheable) > 0 { + _ = s.cache.PutMany(ctx, cacheable) + } } func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) { diff --git a/internal/botapi/bot_commands.go b/internal/botapi/bot_commands.go new file mode 100644 index 00000000..34e8472e --- /dev/null +++ b/internal/botapi/bot_commands.go @@ -0,0 +1,104 @@ +package botapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "telesrv/internal/domain" +) + +const maxBotAPICommands = 100 + +func validateDefaultBotCommandScope(values map[string]string) error { + if strings.TrimSpace(values["language_code"]) != "" { + return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED") + } + raw := strings.TrimSpace(values["scope"]) + if raw == "" { + return nil + } + var scope struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(raw), &scope) != nil || scope.Type != "default" { + return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED") + } + return nil +} + +func (h *handler) setMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + var input []struct { + Command string `json:"command"` + Description string `json:"description"` + IsEphemeral bool `json:"is_ephemeral"` + } + if json.Unmarshal([]byte(values["commands"]), &input) != nil || len(input) > maxBotAPICommands { + writeAPIError(w, http.StatusBadRequest, "BOT_COMMAND_INVALID") + return + } + commands := make([]domain.BotCommand, 0, len(input)) + for _, command := range input { + commands = append(commands, domain.BotCommand{ + Command: command.Command, Description: command.Description, Ephemeral: command.IsEphemeral, + }) + } + if _, err := h.bots.SetBotCommands(r.Context(), botID, commands); err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, true) +} + +func (h *handler) deleteMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := h.bots.SetBotCommands(r.Context(), botID, nil); err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, true) +} + +func (h *handler) getMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + commands, err := h.bots.GetBotCommands(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + out := make([]map[string]any, 0, len(commands)) + for _, command := range commands { + item := map[string]any{"command": command.Command, "description": command.Description} + if command.Ephemeral { + item["is_ephemeral"] = true + } + out = append(out, item) + } + writeAPIOK(w, out) +} diff --git a/internal/botapi/ephemeral.go b/internal/botapi/ephemeral.go new file mode 100644 index 00000000..0ce480b6 --- /dev/null +++ b/internal/botapi/ephemeral.go @@ -0,0 +1,351 @@ +package botapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "telesrv/internal/domain" +) + +type ephemeralSendTarget struct { + receiverUserID int64 + callbackQueryID int64 + replyToEphemeralID int + topMessageID int +} + +func parseEphemeralSendTarget(values map[string]string) (ephemeralSendTarget, bool, error) { + var result ephemeralSendTarget + receiverRaw := strings.TrimSpace(values["receiver_user_id"]) + callbackRaw := strings.TrimSpace(values["callback_query_id"]) + var reply struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + } + if raw := strings.TrimSpace(values["reply_parameters"]); raw != "" { + if json.Unmarshal([]byte(raw), &reply) != nil || reply.MessageID < 0 || reply.EphemeralMessageID < 0 || + (reply.MessageID != 0 && reply.EphemeralMessageID != 0) { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + } + if receiverRaw == "" { + if callbackRaw != "" || reply.EphemeralMessageID != 0 { + return result, false, errors.New("USER_ID_INVALID") + } + return result, false, nil + } + receiver, err := strconv.ParseInt(receiverRaw, 10, 64) + if err != nil || receiver <= 0 { + return result, false, errors.New("USER_ID_INVALID") + } + result.receiverUserID = receiver + result.replyToEphemeralID = reply.EphemeralMessageID + if reply.MessageID != 0 { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + if callbackRaw != "" { + result.callbackQueryID, err = strconv.ParseInt(callbackRaw, 10, 64) + if err != nil || result.callbackQueryID == 0 { + return result, false, errors.New("QUERY_ID_INVALID") + } + } + if result.callbackQueryID != 0 && result.replyToEphemeralID != 0 { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + if raw := strings.TrimSpace(values["message_thread_id"]); raw != "" { + result.topMessageID, err = strconv.Atoi(raw) + if err != nil || result.topMessageID <= 0 || result.topMessageID > domain.MaxMessageBoxID { + return result, false, errors.New("MESSAGE_THREAD_ID_INVALID") + } + } + return result, true, nil +} + +func botAPIFileInput(raw string, files map[string]uploadedFile, field string, values map[string]string) (domain.BotAPIFileInput, bool) { + locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(raw, files, field) + if !ok { + return domain.BotAPIFileInput{}, false + } + return domain.BotAPIFileInput{ + LocationKey: locationKey, RemoteURL: remoteURL, FileName: fileName, MimeType: mimeType, Bytes: fileBytes, + Width: apiInt(values["width"], 0), Height: apiInt(values["height"], 0), Duration: apiInt(values["duration"], 0), + Title: values["title"], Performer: values["performer"], Emoji: values["emoji"], + }, true +} + +func (h *handler) writeEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, message domain.EphemeralMessage) { + users := make([]domain.User, 0, 1) + if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 { + users = append(users, self) + } + projected, ok := apiEphemeralMessage(message, users, nil) + if !ok { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, projected) +} + +func (h *handler) sendEphemeralContact(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + target, ephemeral, err := parseEphemeralSendTarget(values) + if err != nil || !ephemeral { + if err == nil { + err = errors.New("EPHEMERAL_TARGET_REQUIRED") + } + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + if !ok || strings.TrimSpace(values["phone_number"]) == "" || strings.TrimSpace(values["first_name"]) == "" || len(values["vcard"]) > 2048 { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + markup, _, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID, + CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID, + Kind: "contact", ReplyMarkup: markup, DirectMedia: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{ + PhoneNumber: values["phone_number"], FirstName: values["first_name"], LastName: values["last_name"], Vcard: values["vcard"], + }}, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) +} + +func (h *handler) sendEphemeralLocation(w http.ResponseWriter, r *http.Request, botID int64, venue bool) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + target, ephemeral, err := parseEphemeralSendTarget(values) + if err != nil || !ephemeral { + if err == nil { + err = errors.New("EPHEMERAL_TARGET_REQUIRED") + } + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + latitude, latErr := strconv.ParseFloat(strings.TrimSpace(values["latitude"]), 64) + longitude, longErr := strconv.ParseFloat(strings.TrimSpace(values["longitude"]), 64) + accuracy, accuracyErr := strconv.ParseFloat(defaultString(values["horizontal_accuracy"], "0"), 64) + if !ok || latErr != nil || longErr != nil || accuracyErr != nil || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || accuracy < 0 || accuracy > 1500 || apiInt(values["live_period"], 0) != 0 { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + markup, _, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + geo := domain.MessageGeoPoint{Lat: latitude, Long: longitude, AccuracyRadius: int(accuracy)} + media := &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &geo} + if venue { + if strings.TrimSpace(values["title"]) == "" || strings.TrimSpace(values["address"]) == "" { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + provider, venueID, venueType := "", "", "" + if values["foursquare_id"] != "" || values["foursquare_type"] != "" { + provider, venueID, venueType = "foursquare", values["foursquare_id"], values["foursquare_type"] + } else if values["google_place_id"] != "" || values["google_place_type"] != "" { + provider, venueID, venueType = "gplaces", values["google_place_id"], values["google_place_type"] + } + media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: geo, Title: values["title"], Address: values["address"], Provider: provider, VenueID: venueID, VenueType: venueType, + }} + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID, + CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID, + Kind: "location", ReplyMarkup: markup, DirectMedia: media, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) +} + +func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, mode string) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64) + messageID := apiInt(values["ephemeral_message_id"], 0) + if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID") + return + } + input := domain.BotAPIEphemeralEditInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: receiverID, MessageID: messageID, + Mode: domain.EphemeralEditMode(mode), + } + markup, markupSet, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup + switch mode { + case "text": + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities + case "caption": + caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities + case "reply_markup": + input.Fields.SetReplyMarkup = true + case "media": + if err := parseEphemeralEditMedia(values["media"], &input); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + default: + writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + result, err := gateway.BotAPIEditEphemeral(r.Context(), input) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, result) +} + +func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) error { + var media struct { + Type string `json:"type"` + Media string `json:"media"` + Photo string `json:"photo"` + Caption string `json:"caption"` + ParseMode string `json:"parse_mode"` + CaptionEntities json.RawMessage `json:"caption_entities"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + Title string `json:"title"` + Performer string `json:"performer"` + } + if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" { + return errors.New("MEDIA_INVALID") + } + allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true} + if !allowed[media.Type] { + return errors.New("MEDIA_INVALID") + } + primaryRaw := media.Media + if media.Type == "live_photo" { + primaryRaw = media.Photo + } + primary, ok := botAPIFileInput(primaryRaw, nil, "", map[string]string{ + "width": strconv.Itoa(media.Width), "height": strconv.Itoa(media.Height), "duration": strconv.Itoa(media.Duration), + "title": media.Title, "performer": media.Performer, + }) + if !ok || len(primary.Bytes) != 0 { + return errors.New("FILE_ID_INVALID") + } + input.MediaKind, input.File = media.Type, primary + if media.Type == "live_photo" { + secondary, ok := botAPIFileInput(media.Media, nil, "", map[string]string{"duration": strconv.Itoa(media.Duration)}) + if !ok || len(secondary.Bytes) != 0 { + return errors.New("FILE_ID_INVALID") + } + input.SecondaryFile = secondary + } + caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false) + if err != nil { + return err + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities + return nil +} + +func (h *handler) deleteEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64) + messageID := apiInt(values["ephemeral_message_id"], 0) + if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + result, err := gateway.BotAPIDeleteEphemeral(r.Context(), botID, chatID, receiverID, messageID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, result) +} + +func optionalInlineReplyMarkup(values map[string]string) (*domain.MessageReplyMarkup, bool, error) { + raw, exists := values["reply_markup"] + if !exists || strings.TrimSpace(raw) == "" { + return nil, exists, nil + } + markup, err := inlineReplyMarkupFromAPI(json.RawMessage(raw)) + return markup, true, err +} + +func parsePositiveOrNegativeID(raw string) (int64, bool) { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + return id, err == nil && id != 0 +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/internal/botapi/formatted_text.go b/internal/botapi/formatted_text.go new file mode 100644 index 00000000..8f5fc1c1 --- /dev/null +++ b/internal/botapi/formatted_text.go @@ -0,0 +1,1029 @@ +package botapi + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" +) + +// The official Bot API rejects the raw formatted-text input above 32 KiB before +// parsing; the post-parse message/caption character limit is checked separately. +const maxBotAPIFormattedTextBytes = 1 << 15 + +// botAPIFormattedTextRaw applies the Bot API parse_mode/entities precedence and +// returns the plain text plus UTF-16 based entities that can cross the domain +// boundary. A non-empty parse_mode (except "none") deliberately wins over an +// entities payload, matching the official Bot API server. +func botAPIFormattedTextRaw(text, parseMode, rawEntities string, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + mode, enabled, err := botAPIParseMode(parseMode) + if err != nil { + return "", nil, err + } + if enabled && text != "" { + return botAPIFormattedText(text, mode, nil, maxLength, requireText) + } + entities, err := botAPIMessageEntities(rawEntities) + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(text, entities, maxLength, requireText) +} + +func botAPIFormattedText(text, parseMode string, inputEntities []apiMessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + mode, enabled, err := botAPIParseMode(parseMode) + if err != nil { + return "", nil, err + } + if !utf8.ValidString(text) { + return "", nil, errors.New("ENTITY_INVALID") + } + if len(text) > maxBotAPIFormattedTextBytes { + return "", nil, errors.New("MESSAGE_TOO_LONG") + } + if enabled { + var parsed string + var entities []domain.MessageEntity + switch mode { + case "html": + parsed, entities, err = parseBotAPIHTML(text) + case "markdown": + parsed, entities, err = parseBotAPIMarkdown(text) + case "markdownv2": + parsed, entities, err = parseBotAPIMarkdownV2(text) + default: + panic("normalized Bot API parse mode is not handled") + } + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(parsed, entities, maxLength, requireText) + } + entities, err := messageEntitiesFromAPI(inputEntities) + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(text, entities, maxLength, requireText) +} + +func botAPIParseMode(raw string) (mode string, enabled bool, err error) { + mode = strings.ToLower(strings.TrimSpace(raw)) + switch mode { + case "", "none", "null": + return "", false, nil + case "html", "markdown", "markdownv2": + return mode, true, nil + default: + return "", false, errors.New("Unsupported parse_mode") + } +} + +func validateBotAPIFormattedText(text string, entities []domain.MessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + if !utf8.ValidString(text) { + return "", nil, errors.New("ENTITY_INVALID") + } + if requireText && text == "" { + return "", nil, errors.New("MESSAGE_EMPTY") + } + if maxLength > 0 && utf8.RuneCountInString(text) > maxLength { + return "", nil, errors.New("MESSAGE_TOO_LONG") + } + if len(entities) > domain.MaxMessageEntityCount { + return "", nil, errors.New("ENTITIES_TOO_LONG") + } + textLength := utf16StringLength(text) + boundaries := make(map[int]struct{}, utf8.RuneCountInString(text)+1) + boundaries[0] = struct{}{} + position := 0 + for _, r := range text { + position++ + if r > 0xffff { + position++ + } + boundaries[position] = struct{}{} + } + for _, entity := range entities { + if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > textLength || entity.Length > textLength-entity.Offset { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + if _, ok := boundaries[entity.Offset]; !ok { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + if _, ok := boundaries[entity.Offset+entity.Length]; !ok { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + } + sortBotAPIEntities(entities) + ends := make([]int, 0, len(entities)) + for _, entity := range entities { + for len(ends) > 0 && entity.Offset >= ends[len(ends)-1] { + ends = ends[:len(ends)-1] + } + end := entity.Offset + entity.Length + if len(ends) > 0 && end > ends[len(ends)-1] { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + ends = append(ends, end) + } + return text, entities, nil +} + +func utf16StringLength(text string) int { + length := 0 + for _, r := range text { + length++ + if r > 0xffff { + length++ + } + } + return length +} + +type formattedTextBuilder struct { + text bytes.Buffer + utf16 int +} + +func (b *formattedTextBuilder) appendString(value string) { + b.text.WriteString(value) + b.utf16 += utf16StringLength(value) +} + +func (b *formattedTextBuilder) appendRune(r rune) { + b.text.WriteRune(r) + b.utf16++ + if r > 0xffff { + b.utf16++ + } +} + +func (b *formattedTextBuilder) string() string { return b.text.String() } +func (b *formattedTextBuilder) byteLen() int { return b.text.Len() } + +func parseEntityError(format string, args ...any) error { + return fmt.Errorf("Can't parse entities: "+format, args...) +} + +type htmlEntityFrame struct { + tag string + typ domain.MessageEntityType + offset int + outputByte int + argument string + language string + documentID int64 + date int + collapsed bool + relative bool + shortTime bool + longTime bool + shortDate bool + longDate bool + dayOfWeek bool +} + +func parseBotAPIHTML(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + stack := make([]htmlEntityFrame, 0) + for i := 0; i < len(input); { + switch input[i] { + case '&': + decoded, next, err := decodeBotAPIHTMLEntity(input, i) + if err != nil { + return "", nil, err + } + out.appendString(decoded) + i = next + case '<': + closing, tag, attrs, booleans, next, err := scanBotAPIHTMLTag(input, i) + if err != nil { + return "", nil, err + } + if !closing { + frame, frameErr := botAPIHTMLFrame(tag, attrs, booleans, out.utf16, out.byteLen()) + if frameErr != nil { + return "", nil, frameErr + } + stack = append(stack, frame) + } else { + if len(stack) == 0 { + return "", nil, parseEntityError("unexpected end tag at byte offset %d", i) + } + frame := stack[len(stack)-1] + if tag != "" && tag != frame.tag { + return "", nil, parseEntityError("unmatched end tag at byte offset %d, expected , found ", i, frame.tag, tag) + } + stack = stack[:len(stack)-1] + length := out.utf16 - frame.offset + if length > 0 { + if frame.tag == "tg-time" && frame.date <= 0 { + i = next + continue + } + entity := domain.MessageEntity{ + Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language, + DocumentID: frame.documentID, Date: frame.date, Collapsed: frame.collapsed, + Relative: frame.relative, ShortTime: frame.shortTime, LongTime: frame.longTime, + ShortDate: frame.shortDate, LongDate: frame.longDate, DayOfWeek: frame.dayOfWeek, + } + switch frame.tag { + case "a": + link := frame.argument + if link == "" { + link = out.string()[frame.outputByte:] + } + resolved, ok := botAPITextLinkEntity(link, frame.offset, length) + if ok { + entities = append(entities, resolved) + } + case "pre": + if len(entities) > 0 { + last := &entities[len(entities)-1] + if last.Type == domain.MessageEntityCode && last.Offset == frame.offset && last.Length == length && last.Language != "" { + last.Type = domain.MessageEntityPre + break + } + } + entities = append(entities, entity) + default: + entities = append(entities, entity) + } + } + } + i = next + default: + j := i + for j < len(input) && input[j] != '<' && input[j] != '&' { + j++ + } + out.appendString(input[i:j]) + i = j + } + } + if len(stack) > 0 { + return "", nil, parseEntityError("can't find end tag corresponding to start tag <%s>", stack[len(stack)-1].tag) + } + for i := range entities { + if entities[i].Type == domain.MessageEntityCode { + entities[i].Language = "" + } + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +// scanBotAPIHTMLTag parses only the deliberately small HTML dialect accepted by +// the Bot API. It does not apply browser error recovery: malformed and unmatched +// tags must fail before a message state transition. +func scanBotAPIHTMLTag(input string, start int) (closing bool, tag string, attrs map[string]string, booleans map[string]bool, next int, err error) { + if start < 0 || start >= len(input) || input[start] != '<' { + return false, "", nil, nil, start, parseEntityError("invalid tag at byte offset %d", start) + } + i := start + 1 + if i < len(input) && input[i] == '/' { + closing = true + i++ + } + nameStart := i + for i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>' { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed tag at byte offset %d", start) + } + if i == nameStart && !closing { + return false, "", nil, nil, start, parseEntityError("empty tag at byte offset %d", start) + } + tag = strings.ToLower(input[nameStart:i]) + if tag != "" && !supportedBotAPIHTMLTag(tag) { + return false, "", nil, nil, start, parseEntityError("unsupported tag %q at byte offset %d", tag, start) + } + attrs = make(map[string]string) + booleans = make(map[string]bool) + if closing { + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) || input[i] != '>' { + return false, "", nil, nil, start, parseEntityError("unclosed end tag at byte offset %d", start) + } + return true, tag, attrs, booleans, i + 1, nil + } + for { + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag) + } + if input[i] == '>' { + return false, tag, attrs, booleans, i + 1, nil + } + if input[i] == '/' { + return false, "", nil, nil, start, parseEntityError("self-closing tag <%s/> is unsupported", tag) + } + attributeStart := i + for i < len(input) && !isHTMLSpace(input[i]) && !strings.ContainsRune("=>/\"'", rune(input[i])) { + i++ + } + if i == attributeStart { + return false, "", nil, nil, start, parseEntityError("empty attribute name in tag <%s>", tag) + } + name := strings.ToLower(input[attributeStart:i]) + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag) + } + if input[i] != '=' { + booleans[name] = true + continue + } + i++ + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name) + } + var raw string + if input[i] == '\'' || input[i] == '"' { + quote := input[i] + i++ + valueStart := i + for i < len(input) && input[i] != quote { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name) + } + raw = input[valueStart:i] + i++ + } else { + valueStart := i + for i < len(input) && (isASCIIAlphaNumeric(input[i]) || input[i] == '.' || input[i] == '-') { + i++ + } + if i == valueStart || (i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>') { + return false, "", nil, nil, start, parseEntityError("invalid unquoted attribute %q", name) + } + raw = strings.ToLower(input[valueStart:i]) + } + value, decodeErr := decodeBotAPIHTMLString(raw) + if decodeErr != nil { + return false, "", nil, nil, start, decodeErr + } + attrs[name] = value + } +} + +func botAPIHTMLFrame(tag string, attrs map[string]string, booleans map[string]bool, offset, outputByte int) (htmlEntityFrame, error) { + frame := htmlEntityFrame{tag: tag, offset: offset, outputByte: outputByte} + switch tag { + case "b", "strong": + frame.typ = domain.MessageEntityBold + case "i", "em": + frame.typ = domain.MessageEntityItalic + case "u", "ins": + frame.typ = domain.MessageEntityUnderline + case "s", "strike", "del": + frame.typ = domain.MessageEntityStrike + case "tg-spoiler": + frame.typ = domain.MessageEntitySpoiler + case "span": + if attrs["class"] != "tg-spoiler" { + return htmlEntityFrame{}, parseEntityError("tag must have class \"tg-spoiler\"") + } + frame.typ = domain.MessageEntitySpoiler + case "a": + frame.typ, frame.argument = domain.MessageEntityTextURL, attrs["href"] + case "code": + frame.typ = domain.MessageEntityCode + if class := attrs["class"]; strings.HasPrefix(class, "language-") { + frame.language = strings.TrimPrefix(class, "language-") + } + case "pre": + frame.typ = domain.MessageEntityPre + case "blockquote": + frame.typ = domain.MessageEntityBlockquote + _, hasExpandable := attrs["expandable"] + frame.collapsed = booleans["expandable"] || hasExpandable + case "tg-emoji": + frame.typ = domain.MessageEntityCustomEmoji + id, err := strconv.ParseInt(attrs["emoji-id"], 10, 64) + if err != nil || id <= 0 { + return htmlEntityFrame{}, parseEntityError("invalid custom emoji identifier") + } + frame.documentID = id + case "tg-time": + frame.typ = domain.MessageEntityFormattedDate + date, err := strconv.ParseInt(attrs["unix"], 10, 32) + if err != nil { + date = 0 + } + formatted, err := botAPIFormattedDate(1, attrs["format"]) + if err != nil { + return htmlEntityFrame{}, err + } + frame.date = int(date) + frame.relative = formatted.Relative + frame.shortTime = formatted.ShortTime + frame.longTime = formatted.LongTime + frame.shortDate = formatted.ShortDate + frame.longDate = formatted.LongDate + frame.dayOfWeek = formatted.DayOfWeek + default: + return htmlEntityFrame{}, parseEntityError("unsupported tag <%s>", tag) + } + return frame, nil +} + +func supportedBotAPIHTMLTag(tag string) bool { + switch tag { + case "a", "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tg-spoiler", "tg-emoji", "tg-time", "span", "pre", "code", "blockquote": + return true + default: + return false + } +} + +func isHTMLSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' } + +func isASCIIAlphaNumeric(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} + +func decodeBotAPIHTMLString(input string) (string, error) { + if !strings.Contains(input, "&") { + return input, nil + } + var out strings.Builder + for i := 0; i < len(input); { + if input[i] != '&' { + j := strings.IndexByte(input[i:], '&') + if j < 0 { + out.WriteString(input[i:]) + break + } + out.WriteString(input[i : i+j]) + i += j + continue + } + decoded, next, err := decodeBotAPIHTMLEntity(input, i) + if err != nil { + return "", err + } + out.WriteString(decoded) + i = next + } + return out.String(), nil +} + +func decodeBotAPIHTMLEntity(input string, start int) (string, int, error) { + endRelative := strings.IndexByte(input[start:], ';') + if endRelative < 0 { + return "&", start + 1, nil + } + end := start + endRelative + name := input[start+1 : end] + switch name { + case "lt": + return "<", end + 1, nil + case "gt": + return ">", end + 1, nil + case "amp": + return "&", end + 1, nil + case "quot": + return "\"", end + 1, nil + } + if !strings.HasPrefix(name, "#") { + return "&", start + 1, nil + } + base, digits := 10, name[1:] + if strings.HasPrefix(digits, "x") || strings.HasPrefix(digits, "X") { + base, digits = 16, digits[1:] + } + value, err := strconv.ParseInt(digits, base, 32) + if err != nil || value <= 0 || value > utf8.MaxRune || value >= 0xd800 && value <= 0xdfff { + return "&", start + 1, nil + } + return string(rune(value)), end + 1, nil +} + +func parseBotAPIMarkdown(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + for i := 0; i < len(input); { + if input[i] == '\\' && i+1 < len(input) && strings.ContainsRune("_*`[", rune(input[i+1])) { + out.appendRune(rune(input[i+1])) + i += 2 + continue + } + marker := input[i] + if marker != '_' && marker != '*' && marker != '`' && marker != '[' { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + i += size + continue + } + begin := i + typ := domain.MessageEntityItalic + delimiter := string(marker) + language := "" + switch marker { + case '*': + typ = domain.MessageEntityBold + i++ + case '[': + typ, delimiter = domain.MessageEntityTextURL, "]" + i++ + case '`': + typ = domain.MessageEntityCode + if strings.HasPrefix(input[i:], "```") { + typ, delimiter = domain.MessageEntityPre, "```" + i += 3 + languageEnd := i + for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' { + languageEnd++ + } + if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' { + language = input[i:languageEnd] + i = languageEnd + } + i = skipSingleLeadingNewline(input, i) + } else { + i++ + } + default: + i++ + } + offset := out.utf16 + for i < len(input) && !strings.HasPrefix(input[i:], delimiter) { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + i += size + } + if i >= len(input) { + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", begin) + } + length := out.utf16 - offset + i += len(delimiter) + if length <= 0 { + continue + } + if typ == domain.MessageEntityTextURL { + // Derive the visible slice by walking back over exactly the entity's + // UTF-16 range; legacy Markdown doesn't allow nested entities. + visibleStart := outputByteOffsetForUTF16Suffix(out.string(), length) + link := out.string()[visibleStart:] + if i < len(input) && input[i] == '(' { + urlStart := i + 1 + urlEnd := strings.IndexByte(input[urlStart:], ')') + if urlEnd < 0 { + link = input[urlStart:] + i = len(input) + } else { + link = input[urlStart : urlStart+urlEnd] + i = urlStart + urlEnd + 1 + } + } + if entity, ok := botAPITextLinkEntity(link, offset, length); ok { + entities = append(entities, entity) + } + continue + } + entities = append(entities, domain.MessageEntity{Type: typ, Offset: offset, Length: length, Language: language}) + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +func skipSingleLeadingNewline(input string, i int) int { + if i >= len(input) || input[i] != '\n' && input[i] != '\r' { + return i + } + first := input[i] + i++ + if i < len(input) && (input[i] == '\n' || input[i] == '\r') && input[i] != first { + i++ + } + return i +} + +func outputByteOffsetForUTF16Suffix(text string, suffixLength int) int { + need := suffixLength + for i := len(text); i > 0; { + r, size := utf8.DecodeLastRuneInString(text[:i]) + need-- + if r > 0xffff { + need-- + } + i -= size + if need == 0 { + return i + } + } + return 0 +} + +type markdownV2Frame struct { + typ domain.MessageEntityType + offset int + inputByte int + outputByte int + language string +} + +func parseBotAPIMarkdownV2(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + stack := make([]markdownV2Frame, 0) + haveBlockquote, canStartBlockquote := false, true + for i := 0; i < len(input); { + if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 { + literal := input[i+1] + out.appendRune(rune(literal)) + if literal != '\r' { + canStartBlockquote = literal == '\n' + } + i += 2 + continue + } + + reserved := "_*[]()~`>#+-=|{}.!\n" + if len(stack) > 0 && (stack[len(stack)-1].typ == domain.MessageEntityCode || stack[len(stack)-1].typ == domain.MessageEntityPre) { + reserved = "`" + } + if !strings.ContainsRune(reserved, rune(input[i])) { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + if r != '\r' { + canStartBlockquote = false + } + i += size + continue + } + + c := input[i] + endQuote := haveBlockquote && c == '\n' && (i+1 == len(input) || input[i+1] != '>') + isEnd := endQuote || markdownV2ClosesTop(input, i, stack) + if !isEnd { + frame := markdownV2Frame{offset: out.utf16, inputByte: i, outputByte: out.byteLen()} + switch c { + case '_': + frame.typ = domain.MessageEntityItalic + i++ + if i < len(input) && input[i] == '_' { + frame.typ = domain.MessageEntityUnderline + i++ + } + case '*': + frame.typ = domain.MessageEntityBold + i++ + case '~': + frame.typ = domain.MessageEntityStrike + i++ + case '|': + if i+1 >= len(input) || input[i+1] != '|' { + return "", nil, markdownV2ReservedError(c) + } + frame.typ = domain.MessageEntitySpoiler + i += 2 + case '[': + frame.typ = domain.MessageEntityTextURL + i++ + case '!': + if i+1 >= len(input) || input[i+1] != '[' { + return "", nil, markdownV2ReservedError(c) + } + frame.typ = domain.MessageEntityCustomEmoji + i += 2 + case '`': + frame.typ = domain.MessageEntityCode + if strings.HasPrefix(input[i:], "```") { + frame.typ = domain.MessageEntityPre + i += 3 + languageEnd := i + for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' { + languageEnd++ + } + if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' { + frame.language = input[i:languageEnd] + i = languageEnd + } + i = skipSingleLeadingNewline(input, i) + } else { + i++ + } + case '\n': + out.appendRune('\n') + canStartBlockquote = true + i++ + continue + case '>': + if !canStartBlockquote { + return "", nil, markdownV2ReservedError(c) + } + if haveBlockquote { + i++ + continue + } + frame.typ = domain.MessageEntityBlockquote + haveBlockquote = true + i++ + default: + return "", nil, markdownV2ReservedError(c) + } + stack = append(stack, frame) + continue + } + + if len(stack) == 0 { + return "", nil, markdownV2ReservedError(c) + } + collapsed := false + if endQuote { + quoteStart := i + if len(stack) > 0 { + quoteStart = stack[len(stack)-1].inputByte + } + if stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset { + stack = stack[:len(stack)-1] + collapsed = true + } + if len(stack) == 0 || stack[len(stack)-1].typ != domain.MessageEntityBlockquote { + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", quoteStart) + } + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + out.appendRune('\n') + length := out.utf16 - frame.offset + if length > 0 { + entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed}) + } + haveBlockquote, canStartBlockquote = false, true + i++ + continue + } + + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + length := out.utf16 - frame.offset + switch frame.typ { + case domain.MessageEntityBold, domain.MessageEntityItalic, domain.MessageEntityStrike, domain.MessageEntityCode: + i++ + case domain.MessageEntityUnderline, domain.MessageEntitySpoiler: + i += 2 + case domain.MessageEntityPre: + i += 3 + case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji: + i++ // closing ] + link := out.string()[frame.outputByte:] + if i < len(input) && input[i] == '(' { + parsedURL, next, parseErr := parseMarkdownV2URL(input, i+1) + if parseErr != nil { + return "", nil, parseErr + } + link, i = parsedURL, next + } else if frame.typ == domain.MessageEntityCustomEmoji { + return "", nil, parseEntityError("custom emoji entity must contain a tg://emoji or tg://time URL") + } + if length > 0 { + if frame.typ == domain.MessageEntityTextURL { + if entity, ok := botAPITextLinkEntity(link, frame.offset, length); ok { + entities = append(entities, entity) + } + } else { + entity, resolveErr := botAPICustomLinkEntity(link, frame.offset, length) + if resolveErr != nil { + return "", nil, resolveErr + } + entities = append(entities, entity) + } + } + continue + default: + return "", nil, parseEntityError("invalid MarkdownV2 entity") + } + if length > 0 { + entities = append(entities, domain.MessageEntity{Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language}) + } + } + + if haveBlockquote { + collapsed := false + if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset { + stack = stack[:len(stack)-1] + collapsed = true + } + if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntityBlockquote { + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if length := out.utf16 - frame.offset; length > 0 { + entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed}) + } + haveBlockquote = false + } + } + if len(stack) > 0 { + frame := stack[len(stack)-1] + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", frame.inputByte) + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +func markdownV2ClosesTop(input string, i int, stack []markdownV2Frame) bool { + if len(stack) == 0 { + return false + } + c := input[i] + switch stack[len(stack)-1].typ { + case domain.MessageEntityBold: + return c == '*' + case domain.MessageEntityItalic: + return c == '_' && (i+1 >= len(input) || input[i+1] != '_') + case domain.MessageEntityUnderline: + return c == '_' && i+1 < len(input) && input[i+1] == '_' + case domain.MessageEntityStrike: + return c == '~' + case domain.MessageEntitySpoiler: + return c == '|' && i+1 < len(input) && input[i+1] == '|' + case domain.MessageEntityCode: + return c == '`' + case domain.MessageEntityPre: + return strings.HasPrefix(input[i:], "```") + case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji: + return c == ']' + case domain.MessageEntityBlockquote: + return false + default: + return false + } +} + +func markdownV2ReservedError(c byte) error { + return parseEntityError("character %q is reserved and must be escaped with a preceding backslash", c) +} + +func parseMarkdownV2URL(input string, start int) (string, int, error) { + var out strings.Builder + for i := start; i < len(input); { + if input[i] == ')' { + return out.String(), i + 1, nil + } + if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 { + out.WriteByte(input[i+1]) + i += 2 + continue + } + r, size := utf8.DecodeRuneInString(input[i:]) + out.WriteRune(r) + i += size + } + return "", start, parseEntityError("can't find end of URL at byte offset %d", start) +} + +func botAPITextLinkEntity(raw string, offset, length int) (domain.MessageEntity, bool) { + if userID, ok := botAPITGUserID(raw); ok { + return domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: offset, Length: length, UserID: userID}, true + } + if parsed, err := url.Parse(raw); err == nil && strings.EqualFold(parsed.Scheme, "tg") && strings.EqualFold(parsed.Host, "user") { + return domain.MessageEntity{}, false + } + if !validBotAPITextURL(raw) { + return domain.MessageEntity{}, false + } + return domain.MessageEntity{Type: domain.MessageEntityTextURL, Offset: offset, Length: length, URL: raw}, true +} + +func botAPITGUserID(raw string) (int64, bool) { + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "tg") || !strings.EqualFold(parsed.Host, "user") { + return 0, false + } + id, err := strconv.ParseInt(parsed.Query().Get("id"), 10, 64) + return id, err == nil && id > 0 +} + +func validBotAPITextURL(raw string) bool { + if raw == "" || strings.ContainsAny(raw, " \t\r\n") { + return false + } + parsed, err := url.Parse(raw) + if err != nil { + return false + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + return parsed.Host != "" + case "tg": + return parsed.Host != "" + case "mailto", "tel": + return parsed.Opaque != "" || parsed.Path != "" + case "": + return strings.Contains(parsed.Path, ".") + default: + return false + } +} + +func botAPICustomLinkEntity(raw string, offset, length int) (domain.MessageEntity, error) { + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "tg") { + return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL") + } + switch strings.ToLower(parsed.Host) { + case "emoji": + id, parseErr := strconv.ParseInt(parsed.Query().Get("id"), 10, 64) + if parseErr != nil || id <= 0 { + return domain.MessageEntity{}, parseEntityError("invalid custom emoji identifier") + } + return domain.MessageEntity{Type: domain.MessageEntityCustomEmoji, Offset: offset, Length: length, DocumentID: id}, nil + case "time": + date, parseErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32) + if parseErr != nil || date <= 0 { + return domain.MessageEntity{}, parseEntityError("invalid date-time unix value") + } + entity, formatErr := botAPIFormattedDate(int(date), parsed.Query().Get("format")) + if formatErr != nil { + return domain.MessageEntity{}, formatErr + } + entity.Offset, entity.Length = offset, length + return entity, nil + default: + return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL") + } +} + +func botAPIFormattedDate(date int, format string) (domain.MessageEntity, error) { + if date <= 0 || int64(date) > 1<<31-1 { + return domain.MessageEntity{}, parseEntityError("invalid date-time unix value") + } + entity := domain.MessageEntity{Type: domain.MessageEntityFormattedDate, Date: date} + if format == "" { + return entity, nil + } + if format == "r" || format == "R" { + entity.Relative = true + return entity, nil + } + for _, part := range format { + switch part { + case 't': + entity.ShortTime = true + case 'T': + entity.LongTime = true + case 'd': + entity.ShortDate = true + case 'D': + entity.LongDate = true + case 'w', 'W': + entity.DayOfWeek = true + default: + return domain.MessageEntity{}, parseEntityError("invalid date-time format %q", format) + } + } + return entity, nil +} + +func botAPIFormattedDateFormat(entity domain.MessageEntity) string { + if entity.Relative { + return "r" + } + var out strings.Builder + if entity.DayOfWeek { + out.WriteByte('w') + } + if entity.ShortDate { + out.WriteByte('d') + } else if entity.LongDate { + out.WriteByte('D') + } + if entity.ShortTime { + out.WriteByte('t') + } else if entity.LongTime { + out.WriteByte('T') + } + return out.String() +} + +func sortBotAPIEntities(entities []domain.MessageEntity) { + sort.SliceStable(entities, func(i, j int) bool { + if entities[i].Offset != entities[j].Offset { + return entities[i].Offset < entities[j].Offset + } + if entities[i].Length != entities[j].Length { + return entities[i].Length > entities[j].Length + } + return entities[i].Type < entities[j].Type + }) +} diff --git a/internal/botapi/formatted_text_test.go b/internal/botapi/formatted_text_test.go new file mode 100644 index 00000000..ee8783e6 --- /dev/null +++ b/internal/botapi/formatted_text_test.go @@ -0,0 +1,255 @@ +package botapi + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "telesrv/internal/domain" +) + +func TestParseBotAPIHTMLNestedUTF16LinksAndDate(t *testing.T) { + plain, entities, err := parseBotAPIHTML(`A 😀 Alice now`) + if err != nil { + t.Fatal(err) + } + if plain != "A 😀 Alice now" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 4}, + {Type: domain.MessageEntityItalic, Offset: 2, Length: 2}, + {Type: domain.MessageEntityMentionName, Offset: 5, Length: 5, UserID: 42}, + {Type: domain.MessageEntityFormattedDate, Offset: 11, Length: 3, Date: 1700000000, DayOfWeek: true, ShortDate: true, LongTime: true}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPIHTMLPreAndEscapes(t *testing.T) { + plain, entities, err := parseBotAPIHTML(`
if a < b && b > c
`) + if err != nil { + t.Fatal(err) + } + if plain != "if a < b && b > c" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{{Type: domain.MessageEntityPre, Offset: 0, Length: 17, Language: "go"}} + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPILegacyMarkdown(t *testing.T) { + plain, entities, err := parseBotAPIMarkdown(`*bold* _😀_ [site](https://example.com) \*raw\*`) + if err != nil { + t.Fatal(err) + } + if plain != "bold 😀 site *raw*" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 4}, + {Type: domain.MessageEntityItalic, Offset: 5, Length: 2}, + {Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com"}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPIMarkdownV2NestedLinksAndExpandableQuote(t *testing.T) { + plain, entities, err := parseBotAPIMarkdownV2(`*bold _😀_* [site](https://example.com/a\)b) ||secret||`) + if err != nil { + t.Fatal(err) + } + if plain != "bold 😀 site secret" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 7}, + {Type: domain.MessageEntityItalic, Offset: 5, Length: 2}, + {Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com/a)b"}, + {Type: domain.MessageEntitySpoiler, Offset: 13, Length: 6}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } + + plain, entities, err = parseBotAPIMarkdownV2(">visible\n>hidden||") + if err != nil { + t.Fatal(err) + } + if plain != "visible\nhidden" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBlockquote, Offset: 0, Length: 14, Collapsed: true}}) { + t.Fatalf("expandable quote plain=%q entities=%#v", plain, entities) + } +} + +func TestParseBotAPIMarkdownV2FormattedDate(t *testing.T) { + plain, entities, err := parseBotAPIMarkdownV2(`![when](tg://time?unix=1700000000&format=wdT)`) + if err != nil { + t.Fatal(err) + } + want := []domain.MessageEntity{{ + Type: domain.MessageEntityFormattedDate, Offset: 0, Length: 4, Date: 1700000000, + DayOfWeek: true, ShortDate: true, LongTime: true, + }} + if plain != "when" || !reflect.DeepEqual(entities, want) { + t.Fatalf("plain=%q entities=%#v", plain, entities) + } +} + +func TestBotAPIFormattedTextPrecedenceAndEntityBounds(t *testing.T) { + plain, entities, err := botAPIFormattedTextRaw(`ok`, " HTML ", `{not json`, domain.MaxMessageTextLength, true) + if err != nil { + t.Fatal(err) + } + if plain != "ok" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 2}}) { + t.Fatalf("plain=%q entities=%#v", plain, entities) + } + + for name, raw := range map[string]string{ + "unterminated HTML": `broken`, + "reserved MarkdownV2": `plain-text`, + } { + t.Run(name, func(t *testing.T) { + mode := "HTML" + if strings.Contains(name, "MarkdownV2") { + mode = "MarkdownV2" + } + if _, _, err := botAPIFormattedTextRaw(raw, mode, "", domain.MaxMessageTextLength, true); err == nil || !strings.Contains(err.Error(), "Can't parse entities") { + t.Fatalf("error = %v", err) + } + }) + } + + _, _, err = botAPIFormattedText("😀x", "", []apiMessageEntity{{Type: "bold", Offset: 1, Length: 1}}, domain.MaxMessageTextLength, true) + if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" { + t.Fatalf("surrogate-split error = %v", err) + } + _, _, err = botAPIFormattedText("abcdef", "", []apiMessageEntity{ + {Type: "bold", Offset: 0, Length: 4}, + {Type: "italic", Offset: 2, Length: 4}, + }, domain.MaxMessageTextLength, true) + if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" { + t.Fatalf("crossing error = %v", err) + } +} + +func TestBotAPIExplicitExtendedEntitiesRoundTrip(t *testing.T) { + input := []apiMessageEntity{ + {Type: "expandable_blockquote", Offset: 0, Length: 4}, + {Type: "date_time", Offset: 5, Length: 4, UnixTime: 1700000000, DateTimeFormat: "wdT"}, + {Type: "bank_card_number", Offset: 10, Length: 4}, + } + _, entities, err := botAPIFormattedText("text when 1234", "", input, domain.MaxMessageTextLength, true) + if err != nil { + t.Fatal(err) + } + projected := apiMessageEntities(entities, nil) + if projected[0]["type"] != "expandable_blockquote" || projected[1]["type"] != "date_time" || projected[1]["unix_time"] != 1700000000 || projected[1]["date_time_format"] != "wdT" || projected[2]["type"] != "bank_card_number" { + t.Fatalf("projected = %#v", projected) + } +} + +func TestBotAPIInlineAndNestedMediaUseFormattedTextParser(t *testing.T) { + payload := apiInlineResult{InputMessageContent: json.RawMessage(`{ + "message_text":"inline", + "parse_mode":"HTML", + "entities":[{"type":"bold","offset":999,"length":1}] + }`)} + message, entities, _, err := inputTextMessageContentFromAPI(payload) + if err != nil { + t.Fatal(err) + } + if message != "inline" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 6}}) { + t.Fatalf("inline message=%q entities=%#v", message, entities) + } + + fileID := encodeBotAPIFileID("photo:7002:m") + raw, _ := json.Marshal(map[string]any{ + "type": "photo", "media": fileID, "caption": "_media_", "parse_mode": "MarkdownV2", + }) + var input domain.BotAPIEphemeralEditInput + if err := parseEphemeralEditMedia(string(raw), &input); err != nil { + t.Fatal(err) + } + if input.Fields.Message != "media" || !reflect.DeepEqual(input.Fields.Entities, []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}) { + t.Fatalf("media fields=%#v", input.Fields) + } +} + +func TestBotAPIFormattedTextIsUsedByAllMessageEntryPoints(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + sendMessage: domain.Message{ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "hello"}, + sendMediaMessage: domain.Message{ID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "caption"}, + editMessage: domain.Message{ID: 3, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "edited"}, + ephemeralMessage: domain.EphemeralMessage{ID: 4, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, Content: domain.EphemeralContent{Message: "ephemeral"}}, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"hello","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || gateway.sendText != "hello" || len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold { + t.Fatalf("sendMessage status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendText, gateway.sendEntities) + } + + fileID := encodeBotAPIFileID("doc:7001") + body, _ := json.Marshal(map[string]any{"chat_id": 2001, "document": fileID, "caption": "*caption*", "parse_mode": "MarkdownV2"}) + rec = performBotAPIRequest(t, h, bots.profile, "sendDocument", string(body)) + if rec.Code != http.StatusOK || gateway.sendMediaCaption != "caption" || len(gateway.sendMediaEntities) != 1 || gateway.sendMediaEntities[0].Type != domain.MessageEntityBold { + t.Fatalf("sendDocument status=%d body=%s caption=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendMediaCaption, gateway.sendMediaEntities) + } + + rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":3,"text":"_edited_","parse_mode":"Markdown"}`) + if rec.Code != http.StatusOK || gateway.editText != "edited" || len(gateway.editEntities) != 1 || gateway.editEntities[0].Type != domain.MessageEntityItalic { + t.Fatalf("edit status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.editText, gateway.editEntities) + } + + rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"text":"ephemeral","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralSends) == 0 { + t.Fatalf("ephemeral status=%d body=%s", rec.Code, rec.Body.String()) + } + lastSend := gateway.ephemeralSends[len(gateway.ephemeralSends)-1] + if lastSend.Text != "ephemeral" || len(lastSend.Entities) != 1 || lastSend.Entities[0].Type != domain.MessageEntityUnderline { + t.Fatalf("ephemeral status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastSend) + } + + rec = performBotAPIRequest(t, h, bots.profile, "editEphemeralMessageCaption", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":4,"caption":"caption","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralEdits) == 0 { + t.Fatalf("ephemeral edit status=%d body=%s", rec.Code, rec.Body.String()) + } + lastEdit := gateway.ephemeralEdits[len(gateway.ephemeralEdits)-1] + if lastEdit.Fields.Message != "caption" || len(lastEdit.Fields.Entities) != 1 || lastEdit.Fields.Entities[0].Type != domain.MessageEntityStrike { + t.Fatalf("ephemeral edit status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastEdit) + } +} + +func FuzzBotAPIFormattedTextParsersNeverPanic(f *testing.F) { + for _, seed := range []string{"", "plain", "x", "<", "&broken", "*x*", "_", ">quote\n>hidden||", "![x](tg://emoji?id=1)", "😀"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 || !utf8.ValidString(input) { + return + } + _, _, _ = parseBotAPIHTML(input) + _, _, _ = parseBotAPIMarkdown(input) + _, _, _ = parseBotAPIMarkdownV2(input) + }) +} + +func TestBotAPIHTMLParseFailureIsAtomic(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"broken","parse_mode":"HTML"}`) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Can't parse entities") || gateway.sendCalled { + t.Fatalf("status=%d body=%s gatewayCalled=%v", rec.Code, rec.Body.String(), gateway.sendCalled) + } +} diff --git a/internal/botapi/inline.go b/internal/botapi/inline.go index 610ad490..74d03bf5 100644 --- a/internal/botapi/inline.go +++ b/internal/botapi/inline.go @@ -6,7 +6,6 @@ import ( "net/url" "strconv" "strings" - "unicode/utf8" "telesrv/internal/domain" "telesrv/internal/store" @@ -36,7 +35,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) { if err != nil { return domain.BotInlineResult{}, err } - markup, err := replyMarkupFromAPI(payload.ReplyMarkup) + markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup) if err != nil { return domain.BotInlineResult{}, err } @@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M } else if payload.MessageText != "" { content.MessageText = payload.MessageText } - if content.ParseMode != "" { - return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED") - } - message := content.MessageText - if message == "" { - return "", nil, false, errors.New("MESSAGE_EMPTY") - } - if utf8.RuneCountInString(message) > domain.MaxMessageTextLength { - return "", nil, false, errors.New("MESSAGE_TOO_LONG") - } - entities, err := messageEntitiesFromAPI(content.Entities) + message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true) if err != nil { return "", nil, false, err } @@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") } item := domain.MessageEntity{ - Type: mapped, - Offset: entity.Offset, - Length: entity.Length, - URL: entity.URL, - Language: entity.Language, + Type: mapped, + Offset: entity.Offset, + Length: entity.Length, } - if entity.User != nil { - item.UserID = entity.User.ID - } - if entity.CustomEmojiID != "" { + switch mapped { + case domain.MessageEntityTextURL: + resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length) + if !ok { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + item = resolved + case domain.MessageEntityMentionName: + if entity.User != nil { + item.UserID = entity.User.ID + } + if item.UserID <= 0 { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + case domain.MessageEntityPre: + item.Language = entity.Language + case domain.MessageEntityBlockquote: + item.Collapsed = entity.Type == "expandable_blockquote" + case domain.MessageEntityCustomEmoji: id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64) if err != nil || id <= 0 { return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") } item.DocumentID = id + case domain.MessageEntityFormattedDate: + formatted, err := botAPIFormattedDate(entity.UnixTime, entity.DateTimeFormat) + if err != nil { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + formatted.Offset, formatted.Length = entity.Offset, entity.Length + item = formatted } out = append(out, item) } @@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) { return domain.MessageEntitySpoiler, true case "blockquote": return domain.MessageEntityBlockquote, true + case "expandable_blockquote": + return domain.MessageEntityBlockquote, true case "custom_emoji": return domain.MessageEntityCustomEmoji, true case "mention": @@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) { return domain.MessageEntityEmail, true case "phone_number": return domain.MessageEntityPhone, true + case "bank_card_number": + return domain.MessageEntityBankCard, true + case "date_time": + return domain.MessageEntityFormattedDate, true default: return "", false } @@ -165,6 +180,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) if len(raw) == 0 || string(raw) == "null" { return nil, nil } + var shape map[string]json.RawMessage + if err := json.Unmarshal(raw, &shape); err != nil { + return nil, errors.New("BUTTON_INVALID") + } + constructors := 0 + for _, key := range []string{"inline_keyboard", "keyboard", "remove_keyboard", "force_reply"} { + if _, ok := shape[key]; ok { + constructors++ + } + } + if constructors != 1 { + return nil, errors.New("BUTTON_INVALID") + } + if _, ok := shape["inline_keyboard"]; ok { + return inlineKeyboardMarkupFromAPI(raw) + } + if _, ok := shape["keyboard"]; ok { + return replyKeyboardMarkupFromAPI(raw) + } + if _, ok := shape["remove_keyboard"]; ok { + var payload apiReplyKeyboardRemove + if err := json.Unmarshal(raw, &payload); err != nil || !payload.RemoveKeyboard { + return nil, errors.New("BUTTON_INVALID") + } + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: payload.Selective} + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil + } + var payload apiForceReply + if err := json.Unmarshal(raw, &payload); err != nil || !payload.ForceReply { + return nil, errors.New("BUTTON_INVALID") + } + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupForceReply, + SingleUse: true, + Selective: payload.Selective, + Placeholder: payload.InputFieldPlaceholder, + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil +} + +func inlineReplyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { + markup, err := replyMarkupFromAPI(raw) + if err != nil || markup == nil { + return markup, err + } + if markup.Kind() != domain.MessageReplyMarkupInline { + return nil, errors.New("BUTTON_INVALID") + } + return markup, nil +} + +func inlineKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { var payload apiInlineKeyboardMarkup if err := json.Unmarshal(raw, &payload); err != nil { return nil, errors.New("BUTTON_INVALID") @@ -172,7 +245,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) if len(payload.InlineKeyboard) == 0 { return nil, nil } - out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))} + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))} for _, row := range payload.InlineKeyboard { domainRow := make([]domain.MarkupButton, 0, len(row)) for _, button := range row { @@ -193,19 +266,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) return out, nil } -func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { - if button.URL != "" { - return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil +func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { + var payload apiReplyKeyboardMarkup + if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 { + return nil, errors.New("BUTTON_INVALID") } - if button.CallbackData != nil { - if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen { + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: make([][]domain.MarkupButton, 0, len(payload.Keyboard)), + Resize: payload.ResizeKeyboard, + SingleUse: payload.OneTimeKeyboard, + Selective: payload.Selective, + Persistent: payload.IsPersistent, + Placeholder: payload.InputFieldPlaceholder, + } + for _, row := range payload.Keyboard { + domainRow := make([]domain.MarkupButton, 0, len(row)) + for _, button := range row { + if button.Text == "" { + return nil, errors.New("BUTTON_INVALID") + } + if button.Unsupported { + return nil, errors.New("BUTTON_TYPE_INVALID") + } + style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet) + if err != nil { + return nil, err + } + item := domain.MarkupButton{Type: domain.MarkupButtonText, Text: button.Text, Style: style, IconCustomEmojiID: icon} + switch button.Kind { + case "request_contact": + item.Type = domain.MarkupButtonRequestPhone + case "request_location": + item.Type = domain.MarkupButtonRequestLocation + case "request_poll": + item.Type, item.PollType = domain.MarkupButtonRequestPoll, button.PollType + case "request_users": + item.Type, item.ButtonID, item.RequestPeerType = domain.MarkupButtonRequestPeer, button.RequestID, "user" + item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = button.MaxQuantity, button.RequestName, button.RequestUsername, button.RequestPhoto + item.RequestPeerFilter = button.RequestPeerFilter + case "request_chat": + item.Type, item.ButtonID = domain.MarkupButtonRequestPeer, button.RequestID + if button.ChatIsChannel { + item.RequestPeerType = "broadcast" + } else { + item.RequestPeerType = "chat" + } + item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = 1, button.RequestTitle, button.RequestUsername, button.RequestPhoto + item.RequestPeerFilter = button.RequestPeerFilter + case "web_app": + item.Type, item.URL = domain.MarkupButtonSimpleWebView, button.WebAppURL + } + domainRow = append(domainRow, item) + } + out.Keyboard = append(out.Keyboard, domainRow) + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil +} + +func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { + if button.Unsupported { + return domain.MarkupButton{}, errors.New("BUTTON_TYPE_INVALID") + } + constructors := 0 + if button.URLSet { + constructors++ + } + if button.CallbackDataSet { + constructors++ + } + if button.WebAppSet { + constructors++ + } + if button.SwitchInlineSet { + constructors++ + } + if button.CopyTextSet { + constructors++ + } + if constructors != 1 { + return domain.MarkupButton{}, errors.New("BUTTON_INVALID") + } + style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet) + if err != nil { + return domain.MarkupButton{}, err + } + if button.URLSet { + return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil + } + if button.CallbackDataSet { + if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen { return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID") } - return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil + return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(button.CallbackData), Style: style, IconCustomEmojiID: icon}, nil + } + if button.WebAppSet { + return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: button.Text, URL: button.WebAppURL, Style: style, IconCustomEmojiID: icon}, nil + } + if button.SwitchInlineSet { + return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: button.Text, Query: button.SwitchInlineQuery, SamePeer: button.SwitchInlineSamePeer, PeerTypes: append([]string(nil), button.SwitchInlinePeerTypes...), Style: style, IconCustomEmojiID: icon}, nil + } + if button.CopyTextSet { + return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: button.Text, CopyText: button.CopyText, Style: style, IconCustomEmojiID: icon}, nil } return domain.MarkupButton{}, errors.New("BUTTON_INVALID") } +func markupButtonDecorationFromAPI(rawStyle, rawIcon string, iconSet bool) (domain.MarkupButtonStyle, int64, error) { + style := domain.MarkupButtonStyle(strings.TrimSpace(rawStyle)) + switch style { + case "", domain.MarkupButtonStylePrimary, domain.MarkupButtonStyleDanger, domain.MarkupButtonStyleSuccess: + default: + return "", 0, errors.New("BUTTON_INVALID") + } + if !iconSet { + return style, 0, nil + } + icon, err := strconv.ParseInt(strings.TrimSpace(rawIcon), 10, 64) + if err != nil || icon <= 0 { + return "", 0, errors.New("BUTTON_INVALID") + } + return style, icon, nil +} + func replyMarkupErrFromDomain(err error) error { switch { case errors.Is(err, domain.ErrButtonURLInvalid): @@ -279,16 +465,352 @@ type apiMessageEntity struct { User *struct { ID int64 `json:"id"` } `json:"user"` - Language string `json:"language"` - CustomEmojiID string `json:"custom_emoji_id"` + Language string `json:"language"` + CustomEmojiID string `json:"custom_emoji_id"` + UnixTime int `json:"unix_time"` + DateTimeFormat string `json:"date_time_format"` } type apiInlineKeyboardMarkup struct { InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"` } -type apiInlineKeyboardButton struct { - Text string `json:"text"` - URL string `json:"url"` - CallbackData *string `json:"callback_data"` +type apiReplyKeyboardMarkup struct { + Keyboard [][]apiKeyboardButton `json:"keyboard"` + IsPersistent bool `json:"is_persistent"` + ResizeKeyboard bool `json:"resize_keyboard"` + OneTimeKeyboard bool `json:"one_time_keyboard"` + InputFieldPlaceholder string `json:"input_field_placeholder"` + Selective bool `json:"selective"` +} + +type apiKeyboardButton struct { + Text string + Style string + IconCustomEmojiID string + IconCustomEmojiIDSet bool + Unsupported bool + Kind string + PollType string + RequestID int + MaxQuantity int + RequestName bool + RequestUsername bool + RequestPhoto bool + RequestTitle bool + ChatIsChannel bool + WebAppURL string + RequestPeerFilter *domain.BotRequestPeerFilter +} + +type apiChatAdministratorRights struct { + IsAnonymous bool `json:"is_anonymous"` + CanManageChat bool `json:"can_manage_chat"` + CanDeleteMessages bool `json:"can_delete_messages"` + CanManageVideoChats bool `json:"can_manage_video_chats"` + CanRestrictMembers bool `json:"can_restrict_members"` + CanPromoteMembers bool `json:"can_promote_members"` + CanChangeInfo bool `json:"can_change_info"` + CanInviteUsers bool `json:"can_invite_users"` + CanPostStories bool `json:"can_post_stories"` + CanEditStories bool `json:"can_edit_stories"` + CanDeleteStories bool `json:"can_delete_stories"` + CanPostMessages bool `json:"can_post_messages"` + CanEditMessages bool `json:"can_edit_messages"` + CanPinMessages bool `json:"can_pin_messages"` + CanManageTopics bool `json:"can_manage_topics"` + CanManageDirectMessages bool `json:"can_manage_direct_messages"` +} + +func domainRequestAdminRights(in *apiChatAdministratorRights) *domain.BotRequestAdminRights { + if in == nil { + return nil + } + return &domain.BotRequestAdminRights{ + Anonymous: in.IsAnonymous, ManageChat: in.CanManageChat, DeleteMessages: in.CanDeleteMessages, + ManageVideoChats: in.CanManageVideoChats, RestrictMembers: in.CanRestrictMembers, + PromoteMembers: in.CanPromoteMembers, ChangeInfo: in.CanChangeInfo, InviteUsers: in.CanInviteUsers, + PostStories: in.CanPostStories, EditStories: in.CanEditStories, DeleteStories: in.CanDeleteStories, + PostMessages: in.CanPostMessages, EditMessages: in.CanEditMessages, PinMessages: in.CanPinMessages, + ManageTopics: in.CanManageTopics, ManageDirectMessages: in.CanManageDirectMessages, + } +} + +func (b *apiKeyboardButton) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "\"") { + b.Kind = "text" + return json.Unmarshal([]byte(trimmed), &b.Text) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + text, ok := fields["text"] + if !ok || json.Unmarshal(text, &b.Text) != nil { + return errors.New("invalid keyboard button text") + } + if raw, ok := fields["style"]; ok { + if err := json.Unmarshal(raw, &b.Style); err != nil { + return err + } + } + if raw, ok := fields["icon_custom_emoji_id"]; ok { + b.IconCustomEmojiIDSet = true + if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil { + return err + } + } + actions := 0 + if raw, ok := fields["request_contact"]; ok { + var enabled bool + if json.Unmarshal(raw, &enabled) != nil || !enabled { + b.Unsupported = true + } else { + b.Kind = "request_contact" + actions++ + } + } + if raw, ok := fields["request_location"]; ok { + var enabled bool + if json.Unmarshal(raw, &enabled) != nil || !enabled { + b.Unsupported = true + } else { + b.Kind = "request_location" + actions++ + } + } + if raw, ok := fields["request_poll"]; ok { + var poll struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &poll) != nil { + b.Unsupported = true + } else { + b.Kind, b.PollType = "request_poll", poll.Type + actions++ + } + } + if raw, ok := fields["request_users"]; ok { + var request struct { + RequestID int `json:"request_id"` + UserIsBot *bool `json:"user_is_bot"` + UserIsPremium *bool `json:"user_is_premium"` + MaxQuantity int `json:"max_quantity"` + RequestName bool `json:"request_name"` + RequestUsername bool `json:"request_username"` + RequestPhoto bool `json:"request_photo"` + } + if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 { + b.Unsupported = true + } else { + b.Kind, b.RequestID, b.MaxQuantity, b.RequestName, b.RequestUsername, b.RequestPhoto = "request_users", request.RequestID, request.MaxQuantity, request.RequestName, request.RequestUsername, request.RequestPhoto + b.RequestPeerFilter = &domain.BotRequestPeerFilter{} + if request.UserIsBot != nil { + b.RequestPeerFilter.UserIsBotSet, b.RequestPeerFilter.UserIsBot = true, *request.UserIsBot + } + if request.UserIsPremium != nil { + b.RequestPeerFilter.UserIsPremiumSet, b.RequestPeerFilter.UserIsPremium = true, *request.UserIsPremium + } + if b.MaxQuantity == 0 { + b.MaxQuantity = 1 + } + actions++ + } + } + if raw, ok := fields["request_chat"]; ok { + var request struct { + RequestID int `json:"request_id"` + ChatIsChannel bool `json:"chat_is_channel"` + ChatIsForum *bool `json:"chat_is_forum"` + ChatHasUsername *bool `json:"chat_has_username"` + ChatIsCreated bool `json:"chat_is_created"` + UserAdministratorRights *apiChatAdministratorRights `json:"user_administrator_rights"` + BotAdministratorRights *apiChatAdministratorRights `json:"bot_administrator_rights"` + BotIsMember bool `json:"bot_is_member"` + RequestTitle bool `json:"request_title"` + RequestUsername bool `json:"request_username"` + RequestPhoto bool `json:"request_photo"` + } + if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 || (request.ChatIsChannel && (request.ChatIsForum != nil || request.BotIsMember)) { + b.Unsupported = true + } else { + b.Kind, b.RequestID, b.ChatIsChannel, b.RequestTitle, b.RequestUsername, b.RequestPhoto = "request_chat", request.RequestID, request.ChatIsChannel, request.RequestTitle, request.RequestUsername, request.RequestPhoto + b.RequestPeerFilter = &domain.BotRequestPeerFilter{ + ChatIsCreated: request.ChatIsCreated, BotIsMember: request.BotIsMember, + UserAdminRights: domainRequestAdminRights(request.UserAdministratorRights), + BotAdminRights: domainRequestAdminRights(request.BotAdministratorRights), + } + if request.ChatIsForum != nil { + b.RequestPeerFilter.ChatIsForumSet, b.RequestPeerFilter.ChatIsForum = true, *request.ChatIsForum + } + if request.ChatHasUsername != nil { + b.RequestPeerFilter.ChatHasUsernameSet, b.RequestPeerFilter.ChatHasUsername = true, *request.ChatHasUsername + } + actions++ + } + } + if raw, ok := fields["web_app"]; ok { + var app struct { + URL string `json:"url"` + } + if json.Unmarshal(raw, &app) != nil { + b.Unsupported = true + } else { + b.Kind, b.WebAppURL = "web_app", app.URL + actions++ + } + } + if actions == 0 { + b.Kind = "text" + } + if actions > 1 { + b.Unsupported = true + } + for key := range fields { + switch key { + case "text", "style", "icon_custom_emoji_id", "request_contact", "request_location", "request_poll", "request_users", "request_chat", "web_app": + default: + b.Unsupported = true + } + } + return nil +} + +type apiReplyKeyboardRemove struct { + RemoveKeyboard bool `json:"remove_keyboard"` + Selective bool `json:"selective"` +} + +type apiForceReply struct { + ForceReply bool `json:"force_reply"` + InputFieldPlaceholder string `json:"input_field_placeholder"` + Selective bool `json:"selective"` +} + +type apiInlineKeyboardButton struct { + Text string + URL string + URLSet bool + CallbackData string + CallbackDataSet bool + Style string + IconCustomEmojiID string + IconCustomEmojiIDSet bool + Unsupported bool + WebAppURL string + WebAppSet bool + SwitchInlineQuery string + SwitchInlineSet bool + SwitchInlineSamePeer bool + SwitchInlinePeerTypes []string + CopyText string + CopyTextSet bool +} + +func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + text, ok := fields["text"] + if !ok || json.Unmarshal(text, &b.Text) != nil { + return errors.New("invalid inline keyboard button text") + } + if raw, ok := fields["url"]; ok { + b.URLSet = true + if err := json.Unmarshal(raw, &b.URL); err != nil { + return err + } + } + if raw, ok := fields["callback_data"]; ok { + b.CallbackDataSet = true + if err := json.Unmarshal(raw, &b.CallbackData); err != nil { + return err + } + } + if raw, ok := fields["web_app"]; ok { + b.WebAppSet = true + var app struct { + URL string `json:"url"` + } + if json.Unmarshal(raw, &app) != nil { + return errors.New("invalid web app") + } + b.WebAppURL = app.URL + } + switchActions := 0 + if raw, ok := fields["switch_inline_query"]; ok { + switchActions++ + b.SwitchInlineSet = true + if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil { + return errors.New("invalid switch inline query") + } + } + if raw, ok := fields["switch_inline_query_current_chat"]; ok { + switchActions++ + b.SwitchInlineSet, b.SwitchInlineSamePeer = true, true + if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil { + return errors.New("invalid switch inline query") + } + } + if raw, ok := fields["switch_inline_query_chosen_chat"]; ok { + switchActions++ + b.SwitchInlineSet = true + var chosen struct { + Query string `json:"query"` + AllowUserChats bool `json:"allow_user_chats"` + AllowBotChats bool `json:"allow_bot_chats"` + AllowGroupChats bool `json:"allow_group_chats"` + AllowChannelChats bool `json:"allow_channel_chats"` + } + if json.Unmarshal(raw, &chosen) != nil { + return errors.New("invalid switch inline query") + } + b.SwitchInlineQuery = chosen.Query + if chosen.AllowUserChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypePM) + } + if chosen.AllowBotChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBotPM) + } + if chosen.AllowGroupChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup) + } + if chosen.AllowChannelChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBroadcast) + } + } + if switchActions > 1 { + b.Unsupported = true + } + if raw, ok := fields["copy_text"]; ok { + b.CopyTextSet = true + var copy struct { + Text string `json:"text"` + } + if json.Unmarshal(raw, ©) != nil { + return errors.New("invalid copy text") + } + b.CopyText = copy.Text + } + if raw, ok := fields["style"]; ok { + if err := json.Unmarshal(raw, &b.Style); err != nil { + return err + } + } + if raw, ok := fields["icon_custom_emoji_id"]; ok { + b.IconCustomEmojiIDSet = true + if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil { + return err + } + } + for key := range fields { + switch key { + case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id": + default: + b.Unsupported = true + } + } + return nil } diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 5595eddd..668586d7 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -2,12 +2,14 @@ package botapi import ( "encoding/base64" + "encoding/binary" "encoding/json" "errors" "strconv" "strings" "telesrv/internal/domain" + "telesrv/internal/store" ) func apiInt(raw string, fallback int) int { @@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) { return messageEntitiesFromAPI(payload) } -func allowedUpdates(raw string) map[string]struct{} { +func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) { raw = strings.TrimSpace(raw) if raw == "" { - return nil + return nil, errors.New("ALLOWED_UPDATES_INVALID") } var items []string if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil + return nil, errors.New("ALLOWED_UPDATES_INVALID") } - out := make(map[string]struct{}, len(items)) + if len(items) > 100 { + return nil, errors.New("ALLOWED_UPDATES_INVALID") + } + seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items)) + out := make([]domain.BotAPIUpdateKind, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) - if item != "" { - out[item] = struct{}{} + if item == "" || len(item) > 64 { + return nil, errors.New("ALLOWED_UPDATES_INVALID") + } + kind := domain.BotAPIUpdateKind(item) + if _, ok := seen[kind]; !ok { + seen[kind] = struct{}{} + out = append(out, kind) } } - return out + return out, nil } -func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any { +func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any { if limit <= 0 || limit > 100 { limit = 100 } out := make([]map[string]any, 0, min(len(events), limit)) for _, event := range events { - item, kind, ok := apiUpdate(event) - if !ok || !updateAllowed(kind, allowed) { + item, _, ok := apiUpdate(event) + if !ok { continue } out = append(out, item) @@ -73,45 +84,176 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit return out } -func updateAllowed(kind string, allowed map[string]struct{}) bool { - if len(allowed) == 0 { - return true - } - _, ok := allowed[kind] - return ok -} - func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { - if event.Pts <= 0 { + updateID := event.BotAPIUpdateID + if updateID <= 0 { + updateID = int64(event.Pts) + } + if updateID <= 0 { return nil, "", false } switch event.Type { case domain.UpdateEventNewMessage: + if event.EphemeralMessage != nil { + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + return map[string]any{"update_id": updateID, "message": message}, "message", true + } if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ - "update_id": event.Pts, - "message": apiMessage(event.Message, event.Users), + "update_id": updateID, + "message": apiMessage(event.Message, event.Users, event.Channels), }, "message", true case domain.UpdateEventEditMessage: + if event.EphemeralMessage != nil { + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + return map[string]any{"update_id": updateID, "edited_message": message}, "edited_message", true + } if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ - "update_id": event.Pts, - "edited_message": apiMessage(event.Message, event.Users), + "update_id": updateID, + "edited_message": apiMessage(event.Message, event.Users, event.Channels), }, "edited_message", true + case domain.UpdateEventBotCallbackQuery: + callback := event.BotCallbackQuery + if callback == nil || callback.ID == 0 || callback.UserID == 0 { + return nil, "", false + } + var from domain.User + for _, user := range event.Users { + if user.ID == callback.UserID { + from = user + break + } + } + if from.ID == 0 { + from = domain.User{ID: callback.UserID} + } + query := map[string]any{ + "id": strconv.FormatInt(callback.ID, 10), + "from": apiUser(from), + "chat_instance": strconv.FormatInt(callback.ChatInstance, 10), + "data": string(callback.Data), + } + if callback.InlineMessage != nil { + inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage) + if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) { + return nil, "", false + } + query["inline_message_id"] = inlineMessageID + } else if event.EphemeralMessage != nil { + if callback.MessageID <= 0 || event.EphemeralMessage.ID != callback.MessageID || event.EphemeralMessage.Peer != callback.Peer { + return nil, "", false + } + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + query["message"] = message + } else { + if callback.MessageID <= 0 || event.Message.ID != callback.MessageID { + return nil, "", false + } + query["message"] = apiMessage(event.Message, event.Users, event.Channels) + } + return map[string]any{ + "update_id": updateID, + "callback_query": query, + }, "callback_query", true default: return nil, "", false } } +func apiEphemeralMessage(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel) (map[string]any, bool) { + return apiEphemeralMessageDepth(message, users, channels, 0) +} + +func apiEphemeralMessageDepth(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel, depth int) (map[string]any, bool) { + if message.ID <= 0 || message.Peer.Type != domain.PeerTypeChannel || message.Peer.ID <= 0 || + message.SenderUserID <= 0 || message.ReceiverUserID <= 0 || message.Date <= 0 || message.Deleted { + return nil, false + } + if message.Content.Message == "" && (message.Content.Media == nil || message.Content.Media.IsZero()) { + return nil, false + } + projected := apiMessage(domain.Message{ + ID: 0, Peer: message.Peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: message.SenderUserID}, + Date: message.Date, EditDate: message.EditDate, Body: message.Content.Message, + Entities: message.Content.Entities, Media: message.Content.Media, ReplyMarkup: message.Content.ReplyMarkup, + }, users, channels) + projected["message_id"] = 0 + projected["ephemeral_message_id"] = message.ID + receiver := domain.User{ID: message.ReceiverUserID} + for _, user := range users { + if user.ID == message.ReceiverUserID { + receiver = user + break + } + } + projected["receiver_user"] = apiUser(receiver) + if message.ReplyToEphemeralID > 0 { + if depth != 0 || message.BotAPIReply == nil || message.BotAPIReply.ID != message.ReplyToEphemeralID { + return nil, false + } + reply, ok := apiEphemeralMessageDepth(*message.BotAPIReply, users, channels, depth+1) + if !ok { + return nil, false + } + projected["reply_to_message"] = reply + } + return projected, true +} + +const botAPIInlineMessageIDVersion byte = 1 + +// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an +// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the +// version byte lets us reject rather than reinterpret future shapes. +func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) { + if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 { + return "", false + } + buf := make([]byte, 1+4+8+4+8) + buf[0] = botAPIInlineMessageIDVersion + binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID)) + binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID)) + binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID)) + binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash)) + return base64.RawURLEncoding.EncodeToString(buf), true +} + +func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) { + buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion { + return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID") + } + id := domain.BotInlineMessageID{ + DCID: int(binary.LittleEndian.Uint32(buf[1:5])), + OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])), + ID: int(binary.LittleEndian.Uint32(buf[13:17])), + AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])), + } + if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 { + return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID") + } + return id, nil +} + func apiMessageProjectable(msg domain.Message) bool { if msg.Out || msg.ID <= 0 { return false } - return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0 + return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0 } func apiUser(u domain.User) map[string]any { @@ -133,11 +275,17 @@ func apiUser(u domain.User) map[string]any { return out } -func apiMessage(msg domain.Message, users []domain.User) map[string]any { +func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domain.Channel) map[string]any { userByID := map[int64]domain.User{} for _, u := range users { userByID[u.ID] = u } + channelByID := map[int64]domain.Channel{} + if len(channelLists) > 0 { + for _, channel := range channelLists[0] { + channelByID[channel.ID] = channel + } + } out := map[string]any{ "message_id": msg.ID, "date": msg.Date, @@ -153,17 +301,21 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any { } out["from"] = apiUser(from) } - media := apiMessageMedia(msg.Media) + media := apiMessageMedia(msg.Media, userByID, channelByID) if msg.Body != "" { - if len(media) > 0 { + if apiMediaUsesCaption(media) { out["caption"] = msg.Body + } else if poll, ok := media["poll"].(map[string]any); ok { + poll["description"] = msg.Body } else { out["text"] = msg.Body } } if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 { - if len(media) > 0 { + if apiMediaUsesCaption(media) { out["caption_entities"] = entities + } else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" { + poll["description_entities"] = entities } else { out["entities"] = entities } @@ -189,6 +341,15 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any { return out } +func apiMediaUsesCaption(media map[string]any) bool { + for _, key := range []string{"photo", "live_photo", "animation", "audio", "document", "video", "voice"} { + if _, ok := media[key]; ok { + return true + } + } + return false +} + func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any { switch peer.Type { case domain.PeerTypeUser: @@ -241,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) "offset": entity.Offset, "length": entity.Length, } + if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed { + item["type"] = "expandable_blockquote" + } if entity.URL != "" { item["url"] = entity.URL } @@ -257,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) if entity.DocumentID != 0 { item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10) } + if entity.Type == domain.MessageEntityFormattedDate { + item["unix_time"] = entity.Date + item["date_time_format"] = botAPIFormattedDateFormat(entity) + } out = append(out, item) } return out @@ -300,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) { return "email", true case domain.MessageEntityPhone: return "phone_number", true + case domain.MessageEntityBankCard: + return "bank_card_number", true + case domain.MessageEntityFormattedDate: + return "date_time", true default: return "", false } @@ -309,6 +481,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { if markup.IsZero() { return nil } + // Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup, + // ReplyKeyboardRemove and ForceReply are send parameters, not message response fields. + if markup.Kind() != domain.MessageReplyMarkupInline { + return nil + } rows := make([][]map[string]any, 0, len(markup.Inline)) for _, row := range markup.Inline { if len(row) == 0 { @@ -317,11 +494,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { apiRow := make([]map[string]any, 0, len(row)) for _, button := range row { item := map[string]any{"text": button.Text} + if button.Style != "" { + item["style"] = string(button.Style) + } + if button.IconCustomEmojiID > 0 { + item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10) + } switch button.Type { case domain.MarkupButtonURL: item["url"] = button.URL case domain.MarkupButtonCallback: item["callback_data"] = string(button.Data) + case domain.MarkupButtonWebView: + item["web_app"] = map[string]any{"url": button.URL} + case domain.MarkupButtonSwitchInline: + switch { + case button.SamePeer: + item["switch_inline_query_current_chat"] = button.Query + case len(button.PeerTypes) > 0: + chosen := map[string]any{"query": button.Query} + for _, peerType := range button.PeerTypes { + switch peerType { + case store.InlineQueryPeerTypePM: + chosen["allow_user_chats"] = true + case store.InlineQueryPeerTypeBotPM: + chosen["allow_bot_chats"] = true + case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup: + chosen["allow_group_chats"] = true + case store.InlineQueryPeerTypeBroadcast: + chosen["allow_channel_chats"] = true + } + } + item["switch_inline_query_chosen_chat"] = chosen + default: + item["switch_inline_query"] = button.Query + } + case domain.MarkupButtonCopy: + item["copy_text"] = map[string]any{"text": button.CopyText} default: continue } @@ -337,7 +546,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { return map[string]any{"inline_keyboard": rows} } -func apiMessageMedia(media *domain.MessageMedia) map[string]any { +func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, channels map[int64]domain.Channel) map[string]any { if media.IsZero() { return nil } @@ -350,17 +559,342 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any { if len(photos) == 0 { return nil } + if media.LivePhotoVideo != nil { + live := apiDocument(*media.LivePhotoVideo) + live["photo"] = photos + for _, attribute := range media.LivePhotoVideo.Attributes { + if attribute.Kind == domain.DocAttrVideo { + live["width"], live["height"], live["duration"] = attribute.W, attribute.H, int(attribute.Duration) + break + } + } + return map[string]any{"live_photo": live} + } return map[string]any{"photo": photos} case domain.MessageMediaKindDocument: if media.Document == nil { return nil } - return map[string]any{"document": apiDocument(*media.Document)} + return apiDocumentMedia(*media.Document) + case domain.MessageMediaKindContact: + if media.Contact == nil { + return nil + } + contact := map[string]any{ + "phone_number": media.Contact.PhoneNumber, + "first_name": media.Contact.FirstName, + } + if media.Contact.LastName != "" { + contact["last_name"] = media.Contact.LastName + } + if media.Contact.Vcard != "" { + contact["vcard"] = media.Contact.Vcard + } + if media.Contact.UserID != 0 { + contact["user_id"] = media.Contact.UserID + } + return map[string]any{"contact": contact} + case domain.MessageMediaKindGeo: + if media.Geo == nil { + return nil + } + return map[string]any{"location": apiLocation(*media.Geo, nil)} + case domain.MessageMediaKindVenue: + if media.Venue == nil { + return nil + } + return map[string]any{"venue": apiVenue(*media.Venue)} + case domain.MessageMediaKindGeoLive: + if media.GeoLive == nil { + return nil + } + return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)} + case domain.MessageMediaKindPoll: + if media.Poll == nil { + return nil + } + return map[string]any{"poll": apiPoll(*media.Poll, users)} + case domain.MessageMediaKindService: + if media.ServiceAction == nil { + return nil + } + switch media.ServiceAction.Kind { + case domain.MessageServiceActionWebViewDataSent: + if media.ServiceAction.WebViewData == nil { + return nil + } + return map[string]any{"web_app_data": map[string]any{ + "data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText, + }} + case domain.MessageServiceActionRequestedPeer: + return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels) + default: + return nil + } default: return nil } } +func apiDocumentMedia(document domain.Document) map[string]any { + base := apiDocument(document) + for _, attribute := range document.Attributes { + switch attribute.Kind { + case domain.DocAttrSticker: + sticker := cloneAPIMap(base) + sticker["type"], sticker["width"], sticker["height"] = "regular", attribute.W, attribute.H + sticker["is_animated"] = hasDocumentAttribute(document, domain.DocAttrAnimated) + sticker["is_video"] = hasDocumentAttribute(document, domain.DocAttrVideo) + if attribute.Alt != "" { + sticker["emoji"] = attribute.Alt + } + return map[string]any{"sticker": sticker} + case domain.DocAttrAudio: + audio := cloneAPIMap(base) + audio["duration"] = attribute.AudioDuration + if attribute.Voice { + return map[string]any{"voice": audio} + } + if attribute.Title != "" { + audio["title"] = attribute.Title + } + if attribute.Performer != "" { + audio["performer"] = attribute.Performer + } + return map[string]any{"audio": audio} + case domain.DocAttrVideo: + video := cloneAPIMap(base) + video["width"], video["height"], video["duration"] = attribute.W, attribute.H, int(attribute.Duration) + if attribute.RoundMessage { + video["length"] = attribute.W + delete(video, "width") + delete(video, "height") + return map[string]any{"video_note": video} + } + if hasDocumentAttribute(document, domain.DocAttrAnimated) { + return map[string]any{"animation": video, "document": base} + } + return map[string]any{"video": video} + } + } + return map[string]any{"document": base} +} + +func hasDocumentAttribute(document domain.Document, kind domain.DocumentAttributeKind) bool { + for _, attribute := range document.Attributes { + if attribute.Kind == kind { + return true + } + } + return false +} + +func cloneAPIMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)+4) + for key, value := range input { + out[key] = value + } + return out +} + +func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any { + out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long} + if geo.AccuracyRadius > 0 { + out["horizontal_accuracy"] = float64(geo.AccuracyRadius) + } + if live != nil { + if live.Period > 0 { + out["live_period"] = live.Period + } + if live.Heading > 0 { + out["heading"] = live.Heading + } + if live.ProximityNotificationRadius > 0 { + out["proximity_alert_radius"] = live.ProximityNotificationRadius + } + } + return out +} + +func apiVenue(venue domain.MessageVenue) map[string]any { + out := map[string]any{ + "location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address, + } + switch strings.ToLower(venue.Provider) { + case "foursquare": + if venue.VenueID != "" { + out["foursquare_id"] = venue.VenueID + } + if venue.VenueType != "" { + out["foursquare_type"] = venue.VenueType + } + case "gplaces", "google": + if venue.VenueID != "" { + out["google_place_id"] = venue.VenueID + } + if venue.VenueType != "" { + out["google_place_type"] = venue.VenueType + } + } + return out +} + +func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any { + resultByOption := make(map[string]domain.MessagePollAnswerVoters) + totalVoters := 0 + if poll.Results != nil { + totalVoters = poll.Results.TotalVoters + for _, result := range poll.Results.Voters { + resultByOption[string(result.Option)] = result + } + } + options := make([]map[string]any, 0, len(poll.Answers)) + correct := make([]int, 0, len(poll.Answers)) + for index, answer := range poll.Answers { + persistentID := base64.RawURLEncoding.EncodeToString(answer.Option) + if persistentID == "" { + persistentID = strconv.Itoa(index) + } + result := resultByOption[string(answer.Option)] + option := map[string]any{ + "persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters, + } + if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 { + option["text_entities"] = entities + } + if answer.Media != nil { + if projected := apiPollMedia(answer.Media); len(projected) > 0 { + option["media"] = projected + } + } + if result.Correct { + correct = append(correct, index) + } + options = append(options, option) + } + pollType := "regular" + if poll.Quiz { + pollType = "quiz" + } + out := map[string]any{ + "id": strconv.FormatInt(poll.ID, 10), "question": poll.Question, + "options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed, + "is_anonymous": !poll.PublicVoters, "type": pollType, + "allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled, + } + if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 { + out["question_entities"] = entities + } + if len(correct) > 0 { + out["correct_option_ids"] = correct + } + if poll.Results != nil && poll.Results.Solution != "" { + out["explanation"] = poll.Results.Solution + if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 { + out["explanation_entities"] = entities + } + } + if poll.ClosePeriod > 0 { + out["open_period"] = poll.ClosePeriod + } + if poll.CloseDate > 0 { + out["close_date"] = poll.CloseDate + } + if poll.AttachedMedia != nil { + if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 { + out["media"] = projected + } + } + return out +} + +func apiPollMedia(media *domain.MessageMedia) map[string]any { + if media.IsZero() { + return nil + } + switch media.Kind { + case domain.MessageMediaKindPhoto: + if media.Photo != nil { + if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 { + return map[string]any{"photo": sizes} + } + } + case domain.MessageMediaKindDocument: + if media.Document != nil { + return map[string]any{"document": apiDocument(*media.Document)} + } + case domain.MessageMediaKindGeo: + if media.Geo != nil { + return map[string]any{"location": apiLocation(*media.Geo, nil)} + } + case domain.MessageMediaKindVenue: + if media.Venue != nil { + return map[string]any{"venue": apiVenue(*media.Venue)} + } + } + return nil +} + +func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any { + if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 { + return nil + } + allUsers := true + details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details)) + for _, detail := range action.Details { + details[detail.Peer] = detail + } + for _, peer := range action.Peers { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return nil + } + allUsers = allUsers && peer.Type == domain.PeerTypeUser + } + if allUsers { + shared := make([]map[string]any, 0, len(action.Peers)) + for _, peer := range action.Peers { + item := map[string]any{"user_id": peer.ID} + detail := details[peer] + if action.NameRequested { + if detail.FirstName != "" { + item["first_name"] = detail.FirstName + } + if detail.LastName != "" { + item["last_name"] = detail.LastName + } + } + if action.UsernameRequested && detail.Username != "" { + item["username"] = detail.Username + } + if action.PhotoRequested && detail.Photo != nil { + if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 { + item["photo"] = photo + } + } + shared = append(shared, item) + } + return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}} + } + if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel { + return nil + } + peer := action.Peers[0] + shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID} + detail := details[peer] + if action.NameRequested && detail.Title != "" { + shared["title"] = detail.Title + } + if action.UsernameRequested && detail.Username != "" { + shared["username"] = detail.Username + } + if action.PhotoRequested && detail.Photo != nil { + if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 { + shared["photo"] = photo + } + } + return map[string]any{"chat_shared": shared} +} + func apiPhotoSizes(photo domain.Photo) []map[string]any { return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":") } diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 85607a1f..5495724c 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -10,8 +10,10 @@ import ( "io" "net" "net/http" + neturl "net/url" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -21,13 +23,15 @@ import ( type BotsService interface { BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) + SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) + GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) } type UsersService interface { - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) } type WebAppService interface { @@ -41,16 +45,46 @@ type GatewayService interface { BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) + BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error) BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error) } +type EphemeralGatewayService interface { + BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) + BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) + BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) +} + type GatewayUpdateWaiter interface { BotAPIUpdateWaitVersion(botID int64) uint64 WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool } +type GatewayUpdateControl interface { + BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error + BotAPIDropPendingUpdates(ctx context.Context, botID int64) error + BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) +} + +type GatewayPollLease interface { + AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error +} + +type GatewayWebhookControl interface { + BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error + BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error + BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) + ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) + AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error + RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error + RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error + ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error +} + func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, logger *zap.Logger) (*http.Server, error) { if strings.TrimSpace(addr) == "" { return nil, nil @@ -58,7 +92,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic if logger == nil { logger = zap.NewNop() } - handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger} + handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger, webhookClient: newWebhookHTTPClient()} srv := &http.Server{ Addr: addr, Handler: handler.routes(), @@ -76,6 +110,9 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic logger.Warn("Bot API 网关退出", zap.Error(err)) } }() + if webhooks, ok := gateway.(GatewayWebhookControl); ok { + go runWebhookDispatcher(ctx, webhooks, gateway, handler.webhookClient, logger.Named("webhook")) + } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -86,11 +123,37 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic } type handler struct { - bots BotsService - users UsersService - webapps WebAppService - gateway GatewayService - logger *zap.Logger + bots BotsService + users UsersService + webapps WebAppService + gateway GatewayService + logger *zap.Logger + polls botAPIPollRegistry + webhookClient *http.Client +} + +type botAPIPollRegistry struct { + mu sync.Mutex + active map[int64]struct{} +} + +func (p *botAPIPollRegistry) acquire(botID int64) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.active == nil { + p.active = make(map[int64]struct{}) + } + if _, exists := p.active[botID]; exists { + return false + } + p.active[botID] = struct{}{} + return true +} + +func (p *botAPIPollRegistry) release(botID int64) { + p.mu.Lock() + delete(p.active, botID) + p.mu.Unlock() } const ( @@ -131,28 +194,64 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) { switch strings.ToLower(method) { case "getme": h.getMe(w, r, botID) + case "setmycommands": + h.setMyCommands(w, r, botID) + case "deletemycommands": + h.deleteMyCommands(w, r, botID) + case "getmycommands": + h.getMyCommands(w, r, botID) case "getupdates": h.getUpdates(w, r, botID) case "sendmessage": h.sendMessage(w, r, botID) case "sendphoto": h.sendMedia(w, r, botID, "photo") + case "sendanimation": + h.sendMedia(w, r, botID, "animation") + case "sendaudio": + h.sendMedia(w, r, botID, "audio") case "senddocument": h.sendMedia(w, r, botID, "document") + case "sendlivephoto": + h.sendMedia(w, r, botID, "live_photo") + case "sendsticker": + h.sendMedia(w, r, botID, "sticker") + case "sendvideo": + h.sendMedia(w, r, botID, "video") + case "sendvideonote": + h.sendMedia(w, r, botID, "video_note") + case "sendvoice": + h.sendMedia(w, r, botID, "voice") + case "sendcontact": + h.sendEphemeralContact(w, r, botID) + case "sendlocation": + h.sendEphemeralLocation(w, r, botID, false) + case "sendvenue": + h.sendEphemeralLocation(w, r, botID, true) case "editmessagetext": h.editMessageText(w, r, botID) case "deletemessage": h.deleteMessage(w, r, botID) + case "editephemeralmessagetext": + h.editEphemeralMessage(w, r, botID, "text") + case "editephemeralmessagemedia": + h.editEphemeralMessage(w, r, botID, "media") + case "editephemeralmessagecaption": + h.editEphemeralMessage(w, r, botID, "caption") + case "editephemeralmessagereplymarkup": + h.editEphemeralMessage(w, r, botID, "reply_markup") + case "deleteephemeralmessage": + h.deleteEphemeralMessage(w, r, botID) case "answercallbackquery": h.answerCallbackQuery(w, r, botID) case "getfile": h.getFile(w, r, botID) case "deletewebhook": - writeAPIOK(w, true) + h.deleteWebhook(w, r, botID) case "getwebhookinfo": - writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0}) + h.getWebhookInfo(w, r, botID) case "setwebhook": - h.setWebhook(w, r) + h.setWebhook(w, r, botID) case "setchatmenubutton": h.setChatMenuButton(w, r, botID) case "getchatmenubutton": @@ -216,7 +315,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64) + var offset int64 + if raw := strings.TrimSpace(values["offset"]); raw != "" { + offset, err = strconv.ParseInt(raw, 10, 64) + if err != nil || offset < -10000 { + writeAPIError(w, http.StatusBadRequest, "OFFSET_INVALID") + return + } + } limit := apiInt(values["limit"], 100) if limit <= 0 { limit = 100 @@ -231,7 +337,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 if timeoutSeconds > 50 { timeoutSeconds = 50 } - allowed := allowedUpdates(values["allowed_updates"]) + if !h.polls.acquire(botID) { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer h.polls.release(botID) + if leases, ok := h.gateway.(GatewayPollLease); ok { + owner := randomBotAPIOwner() + leaseTTL := time.Duration(timeoutSeconds)*time.Second + 30*time.Second + acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, leaseTTL) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner); err != nil { + h.logger.Warn("release bot api poll lease", zap.Int64("bot_user_id", botID), zap.Error(err)) + } + }() + } + if webhooks, ok := h.gateway.(GatewayWebhookControl); ok { + if _, configured, err := webhooks.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if configured { + writeAPIError(w, http.StatusConflict, "CONFLICT: can't use getUpdates method while webhook is active") + return + } + } + if raw, present := values["allowed_updates"]; present { + allowed, err := parseAllowedUpdates(raw) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + control, ok := h.gateway.(GatewayUpdateControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "ALLOWED_UPDATES_UNSUPPORTED") + return + } + if err := control.BotAPISetAllowedUpdates(r.Context(), botID, allowed); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + } deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second) for { version := botAPIUpdateWaitVersion(h.gateway, botID) @@ -240,7 +395,7 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") return } - updates := apiUpdates(events, allowed, limit) + updates := apiUpdates(events, limit) if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) { writeAPIOK(w, updates) return @@ -249,6 +404,84 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 } } +func randomBotAPIOwner() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err == nil { + return fmt.Sprintf("%x", raw[:]) + } + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) +} + +func (h *handler) deleteWebhook(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + control, ok := h.gateway.(GatewayWebhookControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED") + return + } + leaseOwner := randomBotAPIOwner() + if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if found { + acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, leaseOwner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active") + return + } + defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, leaseOwner) }() + } + if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, true) +} + +func (h *handler) getWebhookInfo(w http.ResponseWriter, r *http.Request, botID int64) { + pending := 0 + if control, ok := h.gateway.(GatewayUpdateControl); ok { + var err error + pending, err = control.BotAPIPendingUpdateCount(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + } + result := map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": pending} + if control, ok := h.gateway.(GatewayWebhookControl); ok { + config, found, err := control.BotAPIWebhook(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if found { + result["url"] = config.URL + result["max_connections"] = config.MaxConnections + if config.AllowedUpdates != nil { + allowed := make([]string, 0, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + allowed = append(allowed, string(kind)) + } + result["allowed_updates"] = allowed + } + if config.LastErrorDate > 0 { + result["last_error_date"] = config.LastErrorDate + result["last_error_message"] = config.LastErrorMessage + } + } + } + writeAPIOK(w, result) +} + func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 { waiter, ok := gateway.(GatewayUpdateWaiter) if !ok { @@ -291,12 +524,7 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6 writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") return } - text := values["text"] - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["entities"]) + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -309,6 +537,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6 return } } + ephemeral, isEphemeral, err := parseEphemeralSendTarget(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if isEphemeral { + if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline { + writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, + CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, + TopMessageID: ephemeral.topMessageID, Kind: "message", Text: text, Entities: entities, ReplyMarkup: markup, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) + return + } replyTo := apiInt(values["reply_to_message_id"], 0) msg, err := h.gateway.BotAPISendMessage(r.Context(), botID, chatID, text, entities, markup, apiBool(values["disable_web_page_preview"]), apiBool(values["disable_notification"]), replyTo) if err != nil { @@ -337,11 +592,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") return } - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["caption_entities"]) + caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -354,12 +605,57 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, return } } - locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind) + var file, secondary domain.BotAPIFileInput + var ok bool + if kind == "live_photo" { + file, ok = botAPIFileInput(values["photo"], files, "photo", values) + if ok { + secondary, ok = botAPIFileInput(values["live_photo"], files, "live_photo", values) + } + } else { + file, ok = botAPIFileInput(values[kind], files, kind, values) + } if !ok { writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID") return } - msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0)) + // The official Bot API does not accept HTTP URLs for the video part of a + // live photo or for video notes. Reject them before either the ordinary or + // ephemeral send path can fetch the remote resource. + if (kind == "live_photo" && secondary.RemoteURL != "") || (kind == "video_note" && file.RemoteURL != "") { + writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID") + return + } + ephemeral, isEphemeral, err := parseEphemeralSendTarget(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if isEphemeral { + if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline { + writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, + CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, + TopMessageID: ephemeral.topMessageID, Kind: kind, Text: caption, Entities: entities, + ReplyMarkup: markup, File: file, SecondaryFile: secondary, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) + return + } + locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes + msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, caption, entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0)) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) return @@ -381,21 +677,25 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) - if err != nil || chatID == 0 { - writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + rawInlineID := strings.TrimSpace(values["inline_message_id"]) + var chatID int64 + messageID := 0 + if rawInlineID == "" { + chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) + if err != nil || chatID == 0 { + writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + return + } + messageID = apiInt(values["message_id"], 0) + if messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID") + return + } + } else if strings.TrimSpace(values["chat_id"]) != "" || strings.TrimSpace(values["message_id"]) != "" { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID") return } - messageID := apiInt(values["message_id"], 0) - if messageID <= 0 { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID") - return - } - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["entities"]) + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -403,13 +703,27 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID var markup *domain.MessageReplyMarkup _, setReplyMarkup := values["reply_markup"] if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { - markup, err = replyMarkupFromAPI(json.RawMessage(raw)) + markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw)) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return } } - msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) + if rawInlineID != "" { + inlineID, err := decodeBotAPIInlineMessageID(rawInlineID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, ok) + return + } + msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) return @@ -557,17 +871,136 @@ func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) { } } -func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) { - values, err := requestValues(r) +func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) { + values, files, err := requestValuesWithFiles(r) if err != nil { writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - if strings.TrimSpace(values["url"]) == "" { + control, ok := h.gateway.(GatewayWebhookControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED") + return + } + rawURL := strings.TrimSpace(values["url"]) + if rawURL == "" { + if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } writeAPIOK(w, true) return } - writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED") + if err := validateWebhookURL(rawURL); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(values["certificate"]) != "" || len(files) != 0 { + writeAPIError(w, http.StatusBadRequest, "CERTIFICATE_PINNING_UNSUPPORTED") + return + } + if strings.TrimSpace(values["ip_address"]) != "" { + writeAPIError(w, http.StatusBadRequest, "IP_ADDRESS_UNSUPPORTED") + return + } + secret := strings.TrimSpace(values["secret_token"]) + if !validWebhookSecret(secret) { + writeAPIError(w, http.StatusBadRequest, "SECRET_TOKEN_INVALID") + return + } + maxConnections := apiInt(values["max_connections"], 40) + if maxConnections < 1 || maxConnections > 100 { + writeAPIError(w, http.StatusBadRequest, "MAX_CONNECTIONS_INVALID") + return + } + var allowed []domain.BotAPIUpdateKind + _, allowedUpdatesSet := values["allowed_updates"] + if raw, present := values["allowed_updates"]; present { + allowed, err = parseAllowedUpdates(raw) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if len(allowed) == 0 { + allowed = nil + } + } + if !h.polls.acquire(botID) { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer h.polls.release(botID) + if leases, ok := h.gateway.(GatewayPollLease); ok { + owner := randomBotAPIOwner() + acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner) + }() + } + webhookOwner := randomBotAPIOwner() + if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if found { + acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, webhookOwner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active") + return + } + defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, webhookOwner) }() + } + if err := control.BotAPISetWebhook(r.Context(), domain.BotAPIWebhook{ + BotUserID: botID, URL: rawURL, SecretToken: secret, + MaxConnections: maxConnections, AllowedUpdates: allowed, AllowedUpdatesSet: allowedUpdatesSet, + }, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, true) +} + +func validateWebhookURL(raw string) error { + if len(raw) > 2048 { + return errors.New("WEBHOOK_URL_INVALID") + } + u, err := neturl.ParseRequestURI(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + return errors.New("WEBHOOK_URL_INVALID") + } + if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" { + return errors.New("WEBHOOK_PORT_NOT_ALLOWED") + } + return nil +} + +func validWebhookSecret(secret string) bool { + if secret == "" { + return true + } + if len(secret) > 256 { + return false + } + for _, r := range secret { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + continue + } + return false + } + return true } func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) { @@ -661,7 +1094,7 @@ func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, bot } until = n } - if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, documentID, until); err != nil { + if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, domain.UserEmojiStatus{DocumentID: documentID, Until: until}); err != nil { if errors.Is(err, domain.ErrPremiumRequired) { writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED") return @@ -911,7 +1344,6 @@ func apiErrorDescription(err error) string { "BOT_INVALID", "CHAT_ID_INVALID", "ENTITY_INVALID", - "ENTITY_PARSE_UNSUPPORTED", "ENTITIES_TOO_LONG", "ENTITY_BOUNDS_INVALID", "ENTITY_TYPE_UNSUPPORTED", @@ -922,6 +1354,9 @@ func apiErrorDescription(err error) string { "QUERY_ID_INVALID", "MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED", + "BOT_COMMAND_INVALID", + "EPHEMERAL_MESSAGE_ID_INVALID", + "EPHEMERAL_ACTION_EXPIRED", "CHAT_WRITE_FORBIDDEN", "CHAT_ADMIN_REQUIRED", "REPLY_MESSAGE_ID_INVALID", diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index c6039595..b6f33227 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "testing" + "time" "telesrv/internal/domain" "telesrv/internal/store" @@ -159,6 +160,62 @@ func TestGetMeUsesGateway(t *testing.T) { } } +func TestBotCommandsPreserveEphemeralFlag(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", `{ + "commands": [ + {"command":"private","description":"Private reply","is_ephemeral":true}, + {"command":"public","description":"Public reply"} + ] + }`) + if rec.Code != http.StatusOK || len(bots.commands) != 2 || !bots.commands[0].Ephemeral || bots.commands[1].Ephemeral { + t.Fatalf("setMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands) + } + + rec = performBotAPIRequest(t, h, bots.profile, "getMyCommands", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("getMyCommands status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + OK bool `json:"ok"` + Result []struct { + Command string `json:"command"` + Description string `json:"description"` + IsEphemeral bool `json:"is_ephemeral"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.OK || len(response.Result) != 2 || !response.Result[0].IsEphemeral || response.Result[1].IsEphemeral { + t.Fatalf("getMyCommands response=%s", rec.Body.String()) + } + + rec = performBotAPIRequest(t, h, bots.profile, "deleteMyCommands", `{}`) + if rec.Code != http.StatusOK || len(bots.commands) != 0 { + t.Fatalf("deleteMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands) + } +} + +func TestBotCommandsRejectUnsupportedScopeAndLanguage(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots}).routes() + + for name, body := range map[string]string{ + "scope": `{"scope":{"type":"all_group_chats"},"commands":[]}`, + "language": `{"language_code":"en","commands":[]}`, + } { + t.Run(name, func(t *testing.T) { + rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BOT_COMMAND_SCOPE_UNSUPPORTED") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -261,6 +318,221 @@ func TestGetUpdatesSkipsOutgoingBotMessage(t *testing.T) { } } +func TestGetUpdatesProjectsEphemeralMessageWithoutPts(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 2001, ReceiverUserID: 1001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "/private"}, + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + Type: domain.UpdateEventNewMessage, BotAPIUpdateID: 901, EphemeralMessage: &message, + Users: []domain.User{{ID: 2001, FirstName: "Alice"}, {ID: 1001, FirstName: "Bot", Bot: true}}, + Channels: []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + OK bool `json:"ok"` + Result []struct { + UpdateID int64 `json:"update_id"` + Message struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + Text string `json:"text"` + ReceiverUser struct { + ID int64 `json:"id"` + } `json:"receiver_user"` + } `json:"message"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !response.OK || len(response.Result) != 1 || response.Result[0].UpdateID != 901 || + response.Result[0].Message.MessageID != 0 || response.Result[0].Message.EphemeralMessageID != 77 || + response.Result[0].Message.ReceiverUser.ID != 1001 || response.Result[0].Message.Text != "/private" { + t.Fatalf("response=%s", rec.Body.String()) + } +} + +func TestEphemeralReplyProjectionContainsValidOneLevelTarget(t *testing.T) { + target := domain.EphemeralMessage{ + ID: 70, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "question"}, + } + message := domain.EphemeralMessage{ + ID: 71, Peer: target.Peer, SenderUserID: 2001, ReceiverUserID: 1001, + Date: 1_900_000_001, ReplyToEphemeralID: target.ID, + Content: domain.EphemeralContent{Message: "answer"}, BotAPIReply: &target, + } + projected, ok := apiEphemeralMessage(message, []domain.User{{ID: 1001, Bot: true}, {ID: 2001}}, []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}}) + if !ok { + t.Fatal("reply was not projectable") + } + reply, ok := projected["reply_to_message"].(map[string]any) + if !ok || reply["message_id"] != 0 || reply["ephemeral_message_id"] != target.ID || reply["date"] != target.Date || reply["text"] != "question" { + t.Fatalf("reply_to_message=%#v", projected["reply_to_message"]) + } +} + +func TestEphemeralSendMethodsRouteAllOfficialMediaKinds(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + ephemeralMessage: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "sent"}, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + chatID := int64(-1000000003001) + documentID := encodeBotAPIFileID("doc:7001") + photoID := encodeBotAPIFileID("photo:7002:m") + tests := []struct { + method string + kind string + body map[string]any + }{ + {"sendMessage", "message", map[string]any{"text": "hello", "message_thread_id": 42}}, + {"sendAnimation", "animation", map[string]any{"animation": documentID}}, + {"sendAudio", "audio", map[string]any{"audio": documentID}}, + {"sendDocument", "document", map[string]any{"document": documentID}}, + {"sendLivePhoto", "live_photo", map[string]any{"photo": photoID, "live_photo": documentID}}, + {"sendPhoto", "photo", map[string]any{"photo": photoID}}, + {"sendSticker", "sticker", map[string]any{"sticker": documentID}}, + {"sendVideo", "video", map[string]any{"video": documentID}}, + {"sendVideoNote", "video_note", map[string]any{"video_note": documentID}}, + {"sendVoice", "voice", map[string]any{"voice": documentID}}, + {"sendContact", "contact", map[string]any{"phone_number": "+100", "first_name": "Alice"}}, + {"sendLocation", "location", map[string]any{"latitude": 1.25, "longitude": 2.5}}, + {"sendVenue", "location", map[string]any{"latitude": 1.25, "longitude": 2.5, "title": "Place", "address": "Street"}}, + } + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + body := test.body + body["chat_id"] = chatID + body["receiver_user_id"] = int64(2001) + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + got := gateway.ephemeralSends[len(gateway.ephemeralSends)-1] + if got.Kind != test.kind || got.ChatID != chatID || got.ReceiverUserID != 2001 { + t.Fatalf("input=%+v", got) + } + var response struct { + OK bool `json:"ok"` + Result struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + ReceiverUser struct { + ID int64 `json:"id"` + } `json:"receiver_user"` + } `json:"result"` + } + if json.Unmarshal(rec.Body.Bytes(), &response) != nil || !response.OK || response.Result.MessageID != 0 || + response.Result.EphemeralMessageID != 77 || response.Result.ReceiverUser.ID != 2001 { + t.Fatalf("response=%s", rec.Body.String()) + } + }) + } + if gateway.ephemeralSends[0].TopMessageID != 42 { + t.Fatalf("message_thread_id=%d", gateway.ephemeralSends[0].TopMessageID) + } +} + +func TestEphemeralSendRejectsOfficiallyUnsupportedMediaURLs(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{} + h := (&handler{bots: bots, gateway: gateway}).routes() + photoID := encodeBotAPIFileID("photo:7002:m") + + tests := []struct { + method string + body map[string]any + }{ + {"sendVideoNote", map[string]any{"video_note": "https://example.com/note.mp4"}}, + {"sendLivePhoto", map[string]any{"photo": photoID, "live_photo": "https://example.com/live.mp4"}}, + } + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + test.body["chat_id"] = int64(-1000000003001) + test.body["receiver_user_id"] = int64(2001) + raw, _ := json.Marshal(test.body) + rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw)) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "FILE_ID_INVALID") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } + if len(gateway.ephemeralSends) != 0 { + t.Fatalf("gateway was called: %+v", gateway.ephemeralSends) + } +} + +func TestEphemeralCallbackReplyEditAndDeleteContracts(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + ephemeralMessage: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "sent"}, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + chatID := int64(-1000000003001) + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"callback_query_id":"991","text":"answer"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralSends) != 1 || gateway.ephemeralSends[0].CallbackQueryID != 991 { + t.Fatalf("callback send status=%d body=%s inputs=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends) + } + rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"reply_parameters":{"ephemeral_message_id":66},"text":"reply"}`) + if rec.Code != http.StatusOK || gateway.ephemeralSends[1].ReplyToEphemeralID != 66 { + t.Fatalf("reply send status=%d body=%s input=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends[1]) + } + + photoID := encodeBotAPIFileID("photo:7002:m") + media, _ := json.Marshal(map[string]any{"type": "photo", "media": photoID, "caption": "new"}) + edits := []struct { + method string + body map[string]any + }{ + {"editEphemeralMessageText", map[string]any{"text": "edited"}}, + {"editEphemeralMessageMedia", map[string]any{"media": json.RawMessage(media)}}, + {"editEphemeralMessageCaption", map[string]any{"caption": "caption"}}, + {"editEphemeralMessageReplyMarkup", map[string]any{"reply_markup": map[string]any{"inline_keyboard": []any{}}}}, + } + for _, edit := range edits { + body := edit.body + body["chat_id"], body["receiver_user_id"], body["ephemeral_message_id"] = chatID, int64(2001), 77 + raw, _ := json.Marshal(body) + rec = performBotAPIRequest(t, h, bots.profile, edit.method, string(raw)) + if rec.Code != http.StatusOK { + t.Fatalf("%s status=%d body=%s", edit.method, rec.Code, rec.Body.String()) + } + } + if len(gateway.ephemeralEdits) != 4 || gateway.ephemeralEdits[0].Mode != domain.EphemeralEditText || + gateway.ephemeralEdits[1].Mode != domain.EphemeralEditMedia || gateway.ephemeralEdits[1].MediaKind != "photo" || + gateway.ephemeralEdits[2].Mode != domain.EphemeralEditCaption || + gateway.ephemeralEdits[3].Mode != domain.EphemeralEditReplyMarkup || !gateway.ephemeralEdits[3].Fields.SetReplyMarkup { + t.Fatalf("edits=%+v", gateway.ephemeralEdits) + } + rec = performBotAPIRequest(t, h, bots.profile, "deleteEphemeralMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":77}`) + if rec.Code != http.StatusOK || !gateway.ephemeralDeleteCalled || gateway.ephemeralDeleteMessageID != 77 { + t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -328,6 +600,312 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) { } } +func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + markup := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}, {Type: domain.MarkupButtonText, Text: "Status"}}}, + Resize: true, + SingleUse: true, + Persistent: true, + Placeholder: "Choose an action", + } + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true}, + sendMessage: domain.Message{ + ID: 10, OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, + Date: 1700000003, Body: "pick", Out: true, ReplyMarkup: markup, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{ + "chat_id":2001, + "text":"pick", + "reply_markup":{ + "keyboard":[["Help",{"text":"Status"}]], + "resize_keyboard":true, + "one_time_keyboard":true, + "is_persistent":true, + "input_field_placeholder":"Choose an action" + } + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if gateway.sendMarkup == nil || gateway.sendMarkup.Kind() != domain.MessageReplyMarkupKeyboard || + len(gateway.sendMarkup.Keyboard) != 1 || len(gateway.sendMarkup.Keyboard[0]) != 2 || + gateway.sendMarkup.Keyboard[0][0].Text != "Help" || !gateway.sendMarkup.Resize || + !gateway.sendMarkup.SingleUse || !gateway.sendMarkup.Persistent || gateway.sendMarkup.Placeholder != "Choose an action" { + t.Fatalf("gateway reply keyboard = %#v", gateway.sendMarkup) + } + var resp struct { + OK bool `json:"ok"` + Result struct { + ReplyMarkup json.RawMessage `json:"reply_markup"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + // Bot API Message.reply_markup only contains InlineKeyboardMarkup; reply keyboards are + // accepted send parameters but are deliberately absent from the returned Message object. + if !resp.OK || len(resp.Result.ReplyMarkup) != 0 { + t.Fatalf("reply keyboard response = %s", rec.Body.String()) + } +} + +func TestGetUpdatesProjectsCallbackQuery(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + callback := &domain.BotCallbackQuery{ + ID: 123456, BotUserID: 1001, UserID: 2001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, MessageID: 9, + ChatInstance: 9988, Data: []byte("confirm"), + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 77, Date: 1700000004, + Peer: callback.Peer, BotCallbackQuery: callback, + Message: domain.Message{ + ID: 9, OwnerUserID: 1001, Peer: callback.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Date: 1700000003, + Body: "tap", Out: true, + }, + Users: []domain.User{{ID: 1001, FirstName: "Echo", Bot: true}, {ID: 2001, FirstName: "Alice"}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"allowed_updates":["callback_query"]}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var resp struct { + OK bool `json:"ok"` + Result []struct { + UpdateID int `json:"update_id"` + CallbackQuery struct { + ID string `json:"id"` + Data string `json:"data"` + ChatInstance string `json:"chat_instance"` + From struct { + ID int64 `json:"id"` + } `json:"from"` + Message struct { + MessageID int `json:"message_id"` + } `json:"message"` + } `json:"callback_query"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || len(resp.Result) != 1 || resp.Result[0].UpdateID != 77 || + resp.Result[0].CallbackQuery.ID != "123456" || resp.Result[0].CallbackQuery.Data != "confirm" || + resp.Result[0].CallbackQuery.ChatInstance != "9988" || resp.Result[0].CallbackQuery.From.ID != 2001 || + resp.Result[0].CallbackQuery.Message.MessageID != 9 { + t.Fatalf("callback update response = %s", rec.Body.String()) + } +} + +func TestInlineCallbackProjectsOpaqueIDAndEditMessageTextUsesIt(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 998877} + callback := &domain.BotCallbackQuery{ + ID: 123456, BotUserID: 1001, UserID: 2001, + ChatInstance: 9988, Data: []byte("inline"), InlineMessage: inline, + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 78, Date: 1700000004, + BotCallbackQuery: callback, Users: []domain.User{{ID: 2001, FirstName: "Alice"}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("getUpdates status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Result []struct { + CallbackQuery struct { + InlineMessageID string `json:"inline_message_id"` + Message json.RawMessage `json:"message"` + } `json:"callback_query"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || len(response.Result) != 1 { + t.Fatalf("callback response=%s err=%v", rec.Body.String(), err) + } + inlineToken := response.Result[0].CallbackQuery.InlineMessageID + decoded, err := decodeBotAPIInlineMessageID(inlineToken) + if err != nil || decoded != *inline || len(response.Result[0].CallbackQuery.Message) != 0 { + t.Fatalf("inline token=%q decoded=%#v message=%s err=%v", inlineToken, decoded, response.Result[0].CallbackQuery.Message, err) + } + edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"inline_message_id":"`+inlineToken+`","text":"updated"}`) + if edit.Code != http.StatusOK || !gateway.editInlineCalled || gateway.editInlineID != *inline { + t.Fatalf("edit status=%d body=%s called=%v id=%#v", edit.Code, edit.Body.String(), gateway.editInlineCalled, gateway.editInlineID) + } +} + +func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) { + tests := []struct { + name string + raw string + kind domain.MessageReplyMarkupType + err string + }{ + {name: "remove", raw: `{"remove_keyboard":true,"selective":true}`, kind: domain.MessageReplyMarkupHide}, + {name: "force", raw: `{"force_reply":true,"input_field_placeholder":"Answer"}`, kind: domain.MessageReplyMarkupForceReply}, + {name: "contact", raw: `{"keyboard":[[{"text":"Phone","request_contact":true}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "filtered users", raw: `{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2,"request_name":true}}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "filtered chat", raw: `{"keyboard":[[{"text":"Forum","request_chat":{"request_id":8,"chat_is_channel":false,"chat_is_forum":true,"chat_has_username":false,"chat_is_created":true,"bot_is_member":true,"user_administrator_rights":{"can_manage_chat":true,"can_delete_messages":true},"bot_administrator_rights":{"can_manage_chat":true}}}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "unsupported legacy user request", raw: `{"keyboard":[[{"text":"User","request_user":{"request_id":1}}]]}`, err: "BUTTON_TYPE_INVALID"}, + {name: "multiple constructors", raw: `{"keyboard":[["A"]],"inline_keyboard":[[{"text":"B","callback_data":"b"}]]}`, err: "BUTTON_INVALID"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + markup, err := replyMarkupFromAPI(json.RawMessage(tt.raw)) + if tt.err != "" { + if err == nil || err.Error() != tt.err { + t.Fatalf("error = %v, want %s", err, tt.err) + } + return + } + if err != nil || markup == nil || markup.Kind() != tt.kind { + t.Fatalf("markup = %#v err=%v, want kind %s", markup, err, tt.kind) + } + }) + } + if _, err := inlineReplyMarkupFromAPI(json.RawMessage(`{"keyboard":[["A"]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("inline-only parser error = %v, want BUTTON_INVALID", err) + } + if _, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Bad","url":"https://example.com","callback_data":"x"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("multi-constructor inline button error = %v, want BUTTON_INVALID", err) + } + filtered, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2}}]]}`)) + if err != nil || filtered == nil { + t.Fatalf("filtered users markup=%#v err=%v", filtered, err) + } + filter := filtered.Keyboard[0][0].RequestPeerFilter + if filter == nil || !filter.UserIsBotSet || filter.UserIsBot || !filter.UserIsPremiumSet || !filter.UserIsPremium { + t.Fatalf("filtered users = %#v", filter) + } + webApp, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"App","web_app":{"url":"https://example.com"}}]]}`)) + if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView { + t.Fatalf("web_app inline button = %#v err=%v", webApp, err) + } +} + +func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) { + reply, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Run","style":"primary","icon_custom_emoji_id":"123"}]]}`)) + if err != nil { + t.Fatalf("reply markup: %v", err) + } + button := reply.Keyboard[0][0] + if button.Style != domain.MarkupButtonStylePrimary || button.IconCustomEmojiID != 123 { + t.Fatalf("reply button = %#v", button) + } + inline, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Delete","callback_data":"delete","style":"danger","icon_custom_emoji_id":"456"}]]}`)) + if err != nil { + t.Fatalf("inline markup: %v", err) + } + button = inline.Inline[0][0] + if button.Style != domain.MarkupButtonStyleDanger || button.IconCustomEmojiID != 456 { + t.Fatalf("inline button = %#v", button) + } + projected := apiReplyMarkup(inline) + rows := projected["inline_keyboard"].([][]map[string]any) + if rows[0][0]["style"] != "danger" || rows[0][0]["icon_custom_emoji_id"] != "456" { + t.Fatalf("projected inline button = %#v", rows[0][0]) + } + if _, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Bad","style":"rainbow"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("invalid style error = %v", err) + } +} + +func TestDeleteWebhookDropsPendingAndWebhookInfoReportsCount(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{pendingCount: 7} + h := (&handler{bots: bots, gateway: gateway}).routes() + info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`) + if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"pending_update_count":7`) { + t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String()) + } + drop := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{"drop_pending_updates":true}`) + if drop.Code != http.StatusOK || !gateway.dropPending { + t.Fatalf("deleteWebhook status=%d body=%s drop=%v", drop.Code, drop.Body.String(), gateway.dropPending) + } +} + +func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{pendingCount: 3} + h := (&handler{bots: bots, gateway: gateway}).routes() + set := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{ + "url":"https://bot.example.test/hook", + "secret_token":"safe_secret-1", + "max_connections":12, + "allowed_updates":["message","callback_query"], + "drop_pending_updates":true + }`) + if set.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != "https://bot.example.test/hook" || + gateway.webhook.SecretToken != "safe_secret-1" || gateway.webhook.MaxConnections != 12 || + len(gateway.webhook.AllowedUpdates) != 2 || !gateway.webhook.AllowedUpdatesSet || !gateway.webhookDrop { + t.Fatalf("setWebhook status=%d body=%s config=%#v", set.Code, set.Body.String(), gateway.webhook) + } + info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`) + if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"url":"https://bot.example.test/hook"`) || + !strings.Contains(info.Body.String(), `"max_connections":12`) || !strings.Contains(info.Body.String(), `"pending_update_count":3`) { + t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String()) + } + reconfigure := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{"url":"https://bot.example.test/new"}`) + if reconfigure.Code != http.StatusOK || gateway.webhook.AllowedUpdatesSet { + t.Fatalf("omitted allowed_updates status=%d body=%s config=%#v", reconfigure.Code, reconfigure.Body.String(), gateway.webhook) + } + poll := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if poll.Code != http.StatusConflict || !strings.Contains(poll.Body.String(), "webhook is active") { + t.Fatalf("getUpdates status=%d body=%s", poll.Code, poll.Body.String()) + } + del := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{}`) + if del.Code != http.StatusOK || !gateway.webhookDeleted || gateway.webhookFound { + t.Fatalf("deleteWebhook status=%d body=%s deleted=%v", del.Code, del.Body.String(), gateway.webhookDeleted) + } +} + +func TestSetWebhookRejectsUnsafeParameters(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes() + tests := []struct { + body string + want string + }{ + {`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"}, + {`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"}, + {`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"}, + {`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"}, + } + for _, tt := range tests { + rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", tt.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tt.want) { + t.Fatalf("setWebhook body=%s status=%d response=%s want=%s", tt.body, rec.Code, rec.Body.String(), tt.want) + } + } +} + +func TestBotAPIPollRegistryRejectsConcurrentPoller(t *testing.T) { + var polls botAPIPollRegistry + if !polls.acquire(1001) { + t.Fatal("first poller was rejected") + } + if polls.acquire(1001) { + t.Fatal("second poller for same bot was accepted") + } + if !polls.acquire(1002) { + t.Fatal("different bot poller was rejected") + } + polls.release(1001) + if !polls.acquire(1001) { + t.Fatal("poller remained locked after release") + } +} + func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -554,6 +1132,108 @@ func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) { } } +func TestAPIMessageProjectsReplyKeyboardResponses(t *testing.T) { + base := domain.Message{ + ID: 10, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000010, + } + t.Run("contact", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{ + PhoneNumber: "+12025550123", FirstName: "Alice", LastName: "Example", Vcard: "VCARD", UserID: 2001, + }} + contact := apiMessage(msg, nil)["contact"].(map[string]any) + if contact["phone_number"] != "+12025550123" || contact["user_id"] != int64(2001) { + t.Fatalf("contact=%#v", contact) + } + }) + t.Run("locations", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{Lat: 1.5, Long: 2.5, AccuracyRadius: 7}} + location := apiMessage(msg, nil)["location"].(map[string]any) + if location["latitude"] != 1.5 || location["horizontal_accuracy"] != float64(7) { + t.Fatalf("location=%#v", location) + } + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{ + Geo: domain.MessageGeoPoint{Lat: 3.5, Long: 4.5}, Period: 60, Heading: 90, ProximityNotificationRadius: 25, + }} + location = apiMessage(msg, nil)["location"].(map[string]any) + if location["live_period"] != 60 || location["heading"] != 90 || location["proximity_alert_radius"] != 25 { + t.Fatalf("live location=%#v", location) + } + }) + t.Run("venue", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: domain.MessageGeoPoint{Lat: 1, Long: 2}, Title: "Cafe", Address: "Main St", + Provider: "foursquare", VenueID: "place-1", VenueType: "food/cafe", + }} + venue := apiMessage(msg, nil)["venue"].(map[string]any) + if venue["title"] != "Cafe" || venue["foursquare_id"] != "place-1" { + t.Fatalf("venue=%#v", venue) + } + }) + t.Run("poll", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{ + ID: 77, Question: "Pick", Quiz: true, RevotingDisabled: true, + Answers: []domain.MessagePollAnswer{{Text: "A", Option: []byte{1}}, {Text: "B", Option: []byte{2}}}, + Results: &domain.MessagePollResults{TotalVoters: 3, Voters: []domain.MessagePollAnswerVoters{ + {Option: []byte{1}, Voters: 1}, {Option: []byte{2}, Voters: 2, Correct: true}, + }, Solution: "Because B"}, + }} + poll := apiMessage(msg, nil)["poll"].(map[string]any) + options := poll["options"].([]map[string]any) + correct := poll["correct_option_ids"].([]int) + if poll["id"] != "77" || poll["type"] != "quiz" || poll["allows_revoting"] != false || + len(options) != 2 || options[1]["voter_count"] != 2 || len(correct) != 1 || correct[0] != 1 { + t.Fatalf("poll=%#v", poll) + } + }) + t.Run("web_app_data", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ButtonText: "Open", Data: `{"ok":true}`}, + }} + data := apiMessage(msg, nil)["web_app_data"].(map[string]any) + if data["button_text"] != "Open" || data["data"] != `{"ok":true}` { + t.Fatalf("web_app_data=%#v", data) + } + }) + t.Run("shared_peers", func(t *testing.T) { + msg := base + sharedPhoto := domain.Photo{ID: 9001, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 42, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 3001}}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3001}, FirstName: "Shared", Username: "shared_user", Photo: &sharedPhoto, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }} + projected := apiMessage(msg, nil) + usersShared := projected["users_shared"].(map[string]any) + sharedUsers := usersShared["users"].([]map[string]any) + if usersShared["request_id"] != 42 || sharedUsers[0]["user_id"] != int64(3001) || sharedUsers[0]["username"] != "shared_user" || len(sharedUsers[0]["photo"].([]map[string]any)) != 1 { + t.Fatalf("users_shared=%#v", usersShared) + } + msg.Media.ServiceAction.RequestedPeer.Peers = []domain.Peer{{Type: domain.PeerTypeChannel, ID: 55}} + msg.Media.ServiceAction.RequestedPeer.Details = []domain.MessageRequestedPeerDetails{{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 55}, Title: "Shared Chat", Username: "shared_chat", + }} + projected = apiMessage(msg, nil) + chatShared := projected["chat_shared"].(map[string]any) + if chatShared["request_id"] != 42 || chatShared["chat_id"] != int64(-1000000000055) || chatShared["title"] != "Shared Chat" { + t.Fatalf("chat_shared=%#v", chatShared) + } + }) +} + func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder { t.Helper() token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret) @@ -571,13 +1251,23 @@ type apiResponse struct { } type fakeBotAPIBots struct { - profile domain.BotProfile + profile domain.BotProfile + commands []domain.BotCommand } func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) { return f.profile, true, nil } +func (f *fakeBotAPIBots) SetBotCommands(_ context.Context, _ int64, commands []domain.BotCommand) (int, error) { + f.commands = append([]domain.BotCommand(nil), commands...) + return 1, nil +} + +func (f *fakeBotAPIBots) GetBotCommands(context.Context, int64) ([]domain.BotCommand, error) { + return append([]domain.BotCommand(nil), f.commands...), nil +} + func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) { return 0, nil } @@ -629,31 +1319,51 @@ type fakeBotAPIGateway struct { updateBotID int64 updateOffset int64 - sendCalled bool - sendBotID int64 - sendChatID int64 - sendText string - sendEntities []domain.MessageEntity - sendMarkup *domain.MessageReplyMarkup - sendNoWebpage bool - sendSilent bool - sendReplyTo int - sendMessage domain.Message - sendMediaCalled bool - sendMediaKind string - sendMediaChatID int64 - sendMediaFileName string - sendMediaBytes []byte - sendMediaCaption string - sendMediaMessage domain.Message - editCalled bool - editSetMarkup bool - editMessage domain.Message - deleteCalled bool - callbackCalled bool - callbackID string - fileLocationKey string - fileChunks map[string]domain.FileChunk + sendCalled bool + sendBotID int64 + sendChatID int64 + sendText string + sendEntities []domain.MessageEntity + sendMarkup *domain.MessageReplyMarkup + sendNoWebpage bool + sendSilent bool + sendReplyTo int + sendMessage domain.Message + sendMediaCalled bool + sendMediaKind string + sendMediaChatID int64 + sendMediaFileName string + sendMediaBytes []byte + sendMediaCaption string + sendMediaEntities []domain.MessageEntity + sendMediaMessage domain.Message + editCalled bool + editText string + editEntities []domain.MessageEntity + editSetMarkup bool + editMessage domain.Message + editInlineCalled bool + editInlineID domain.BotInlineMessageID + editInlineText string + editInlineEntities []domain.MessageEntity + deleteCalled bool + callbackCalled bool + callbackID string + fileLocationKey string + fileChunks map[string]domain.FileChunk + allowedUpdates []domain.BotAPIUpdateKind + dropPending bool + pendingCount int + webhook domain.BotAPIWebhook + webhookFound bool + webhookDeleted bool + webhookDrop bool + webhookConfirmed int64 + ephemeralMessage domain.EphemeralMessage + ephemeralSends []domain.BotAPIEphemeralSendInput + ephemeralEdits []domain.BotAPIEphemeralEditInput + ephemeralDeleteCalled bool + ephemeralDeleteMessageID int } func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) { @@ -666,6 +1376,65 @@ func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset return append([]domain.UpdateEvent(nil), f.updates...), nil } +func (f *fakeBotAPIGateway) BotAPISetAllowedUpdates(_ context.Context, _ int64, allowed []domain.BotAPIUpdateKind) error { + f.allowedUpdates = append([]domain.BotAPIUpdateKind(nil), allowed...) + return nil +} + +func (f *fakeBotAPIGateway) BotAPIDropPendingUpdates(context.Context, int64) error { + f.dropPending = true + return nil +} + +func (f *fakeBotAPIGateway) BotAPIPendingUpdateCount(context.Context, int64) (int, error) { + return f.pendingCount, nil +} + +func (f *fakeBotAPIGateway) BotAPISetWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error { + f.webhook, f.webhookFound, f.webhookDrop = config, true, dropPending + return nil +} + +func (f *fakeBotAPIGateway) BotAPIDeleteWebhook(_ context.Context, _ int64, dropPending bool) error { + f.webhook, f.webhookFound, f.webhookDeleted, f.webhookDrop = domain.BotAPIWebhook{}, false, true, dropPending + if dropPending { + f.dropPending = true + } + return nil +} + +func (f *fakeBotAPIGateway) BotAPIWebhook(context.Context, int64) (domain.BotAPIWebhook, bool, error) { + return f.webhook, f.webhookFound, nil +} + +func (f *fakeBotAPIGateway) ListDueBotAPIWebhooks(context.Context, int) ([]domain.BotAPIWebhook, error) { + if !f.webhookFound { + return nil, nil + } + return []domain.BotAPIWebhook{f.webhook}, nil +} + +func (f *fakeBotAPIGateway) AcquireBotAPIWebhookLease(context.Context, int64, string, time.Duration) (bool, error) { + return true, nil +} + +func (f *fakeBotAPIGateway) ReleaseBotAPIWebhookLease(context.Context, int64, string) error { + return nil +} + +func (f *fakeBotAPIGateway) RecordBotAPIWebhookFailure(context.Context, int64, string, time.Time, string) error { + return nil +} + +func (f *fakeBotAPIGateway) RecordBotAPIWebhookSuccess(context.Context, int64, string, time.Time) error { + return nil +} + +func (f *fakeBotAPIGateway) ConfirmBotAPIWebhookDelivery(_ context.Context, _ int64, updateID int64) error { + f.webhookConfirmed = updateID + return nil +} + func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) { f.sendCalled = true f.sendBotID = botID @@ -686,15 +1455,25 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int f.sendMediaFileName = fileName f.sendMediaBytes = append([]byte(nil), fileBytes...) f.sendMediaCaption = caption + f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...) return f.sendMediaMessage, nil } func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) { f.editCalled = true + f.editText = text + f.editEntities = append([]domain.MessageEntity(nil), entities...) f.editSetMarkup = setReplyMarkup return f.editMessage, nil } +func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) { + f.editInlineCalled, f.editInlineID = true, inlineMessageID + f.editInlineText = text + f.editInlineEntities = append([]domain.MessageEntity(nil), entities...) + return true, nil +} + func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) { f.deleteCalled = true return true, nil @@ -723,3 +1502,19 @@ func (f *fakeBotAPIGateway) BotAPIGetFile(_ context.Context, _ int64, locationKe out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...) return out, true, nil } + +func (f *fakeBotAPIGateway) BotAPISendEphemeral(_ context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) { + f.ephemeralSends = append(f.ephemeralSends, input) + return f.ephemeralMessage, nil +} + +func (f *fakeBotAPIGateway) BotAPIEditEphemeral(_ context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) { + f.ephemeralEdits = append(f.ephemeralEdits, input) + return true, nil +} + +func (f *fakeBotAPIGateway) BotAPIDeleteEphemeral(_ context.Context, _ int64, _ int64, _ int64, messageID int) (bool, error) { + f.ephemeralDeleteCalled = true + f.ephemeralDeleteMessageID = messageID + return true, nil +} diff --git a/internal/botapi/webhook.go b/internal/botapi/webhook.go new file mode 100644 index 00000000..8a480258 --- /dev/null +++ b/internal/botapi/webhook.go @@ -0,0 +1,259 @@ +package botapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const ( + webhookScanInterval = 250 * time.Millisecond + webhookLeaseTTL = 30 * time.Second + webhookIdleDelay = time.Hour + webhookBotWorkers = 16 + webhookHTTPWorkers = 64 + webhookDueBatch = 64 +) + +type webhookDispatcher struct { + control GatewayWebhookControl + gateway GatewayService + client *http.Client + logger *zap.Logger + botSem chan struct{} + httpSem chan struct{} +} + +func newWebhookHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 256, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + } + return &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + // A redirect could leak X-Telegram-Bot-Api-Secret-Token to another host. + return http.ErrUseLastResponse + }, + } +} + +func runWebhookDispatcher(ctx context.Context, control GatewayWebhookControl, gateway GatewayService, client *http.Client, logger *zap.Logger) { + if control == nil || gateway == nil { + return + } + if client == nil { + client = newWebhookHTTPClient() + } + if logger == nil { + logger = zap.NewNop() + } + d := &webhookDispatcher{ + control: control, gateway: gateway, client: client, logger: logger, + botSem: make(chan struct{}, webhookBotWorkers), httpSem: make(chan struct{}, webhookHTTPWorkers), + } + d.scan(ctx) + ticker := time.NewTicker(webhookScanInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.scan(ctx) + } + } +} + +func (d *webhookDispatcher) scan(ctx context.Context) { + configs, err := d.control.ListDueBotAPIWebhooks(ctx, webhookDueBatch) + if err != nil { + d.logger.Warn("list due bot api webhooks", zap.Error(err)) + return + } + for _, config := range configs { + select { + case d.botSem <- struct{}{}: + go func(config domain.BotAPIWebhook) { + defer func() { <-d.botSem }() + d.deliver(ctx, config) + }(config) + default: + return + } + } +} + +func (d *webhookDispatcher) deliver(parent context.Context, candidate domain.BotAPIWebhook) { + ctx, cancel := context.WithTimeout(parent, webhookLeaseTTL) + defer cancel() + owner := randomBotAPIOwner() + acquired, err := d.control.AcquireBotAPIWebhookLease(ctx, candidate.BotUserID, owner, webhookLeaseTTL) + if err != nil { + d.logger.Warn("acquire bot api webhook lease", zap.Int64("bot_user_id", candidate.BotUserID), zap.Error(err)) + return + } + if !acquired { + return + } + released := false + defer func() { + if released { + return + } + releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer releaseCancel() + _ = d.control.ReleaseBotAPIWebhookLease(releaseCtx, candidate.BotUserID, owner) + }() + + // Re-read after taking the lease so a stale due-list row can never deliver to + // a URL that has since been deleted or replaced. + config, found, err := d.control.BotAPIWebhook(ctx, candidate.BotUserID) + if err != nil || !found { + return + } + events, err := d.gateway.BotAPIUpdates(ctx, config.BotUserID, 0) + if err != nil { + d.fail(ctx, config, owner, fmt.Errorf("load updates: %w", err)) + released = true + return + } + if len(events) == 0 { + err = d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, time.Now().Add(webhookIdleDelay)) + if err != nil { + d.logger.Warn("idle bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + } + released = err == nil + return + } + + limit := config.MaxConnections + if limit <= 0 || limit > 100 { + limit = 40 + } + if limit > len(events) { + limit = len(events) + } + type delivery struct { + index int + updateID int64 + err error + } + results := make(chan delivery, limit) + for i := 0; i < limit; i++ { + item, _, ok := apiUpdate(events[i]) + if !ok { + results <- delivery{index: i, updateID: int64(events[i].Pts), err: errors.New("update projection failed")} + continue + } + payload, err := json.Marshal(item) + if err != nil { + results <- delivery{index: i, updateID: int64(events[i].Pts), err: err} + continue + } + go func(index int, updateID int64, payload []byte) { + select { + case d.httpSem <- struct{}{}: + defer func() { <-d.httpSem }() + case <-ctx.Done(): + results <- delivery{index: index, updateID: updateID, err: ctx.Err()} + return + } + results <- delivery{index: index, updateID: updateID, err: d.post(ctx, config, payload)} + }(i, int64(events[i].Pts), payload) + } + deliveries := make([]delivery, limit) + for i := 0; i < limit; i++ { + result := <-results + deliveries[result.index] = result + } + confirmedID := int64(0) + var firstErr error + for _, result := range deliveries { + if result.err != nil { + firstErr = result.err + break + } + confirmedID = result.updateID + } + if confirmedID > 0 { + if err := d.control.ConfirmBotAPIWebhookDelivery(ctx, config.BotUserID, confirmedID); err != nil { + firstErr = fmt.Errorf("confirm update %d: %w", confirmedID, err) + } + } + if firstErr != nil { + d.fail(ctx, config, owner, firstErr) + released = true + return + } + nextAttempt := time.Now() + if limit == len(events) && len(events) < 100 { + nextAttempt = nextAttempt.Add(webhookIdleDelay) + } + if err := d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, nextAttempt); err != nil { + d.logger.Warn("complete bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + return + } + released = true +} + +func (d *webhookDispatcher) post(ctx context.Context, config domain.BotAPIWebhook, payload []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if config.SecretToken != "" { + req.Header.Set("X-Telegram-Bot-Api-Secret-Token", config.SecretToken) + } + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode) + } + return nil +} + +func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhook, owner string, cause error) { + exponent := config.FailureCount + if exponent < 0 { + exponent = 0 + } + if exponent > 8 { + exponent = 8 + } + delay := time.Second * time.Duration(1< 5*time.Minute { + delay = 5 * time.Minute + } + // Small deterministic jitter prevents synchronized retries without a global RNG lock. + delay += time.Duration(config.BotUserID&255) * time.Millisecond + message := cause.Error() + if err := d.control.RecordBotAPIWebhookFailure(ctx, config.BotUserID, owner, time.Now().Add(delay), message); err != nil { + d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + return + } + d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message)) +} diff --git a/internal/botapi/webhook_test.go b/internal/botapi/webhook_test.go new file mode 100644 index 00000000..f31ad385 --- /dev/null +++ b/internal/botapi/webhook_test.go @@ -0,0 +1,127 @@ +package botapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +type recordingWebhookGateway struct { + *fakeBotAPIGateway + mu sync.Mutex + failure string + failureNext time.Time + successNext time.Time + recordedOwner string +} + +func (g *recordingWebhookGateway) RecordBotAPIWebhookFailure(_ context.Context, _ int64, owner string, next time.Time, message string) error { + g.mu.Lock() + g.recordedOwner, g.failure, g.failureNext = owner, message, next + g.mu.Unlock() + return nil +} + +func (g *recordingWebhookGateway) RecordBotAPIWebhookSuccess(_ context.Context, _ int64, owner string, next time.Time) error { + g.mu.Lock() + g.recordedOwner, g.successNext = owner, next + g.mu.Unlock() + return nil +} + +func webhookEvents(ids ...int) []domain.UpdateEvent { + out := make([]domain.UpdateEvent, 0, len(ids)) + for _, id := range ids { + out = append(out, domain.UpdateEvent{ + UserID: 1001, Type: domain.UpdateEventNewMessage, Pts: id, Date: 1700000000 + id, + Message: domain.Message{ + ID: id, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000000 + id, Body: "message", Out: false, + }, + Users: []domain.User{{ID: 2001, FirstName: "Alice"}}, + }) + } + return out +} + +func TestWebhookDispatcherPostsInParallelWithSecretAndConfirmsContiguousBatch(t *testing.T) { + var mu sync.Mutex + received := make(map[int]bool) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token"); got != "secret_1" { + t.Errorf("secret header = %q", got) + } + var update struct { + UpdateID int `json:"update_id"` + } + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + t.Errorf("decode webhook: %v", err) + } + mu.Lock() + received[update.UpdateID] = true + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + base := &fakeBotAPIGateway{ + updates: webhookEvents(11, 12, 13), + webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, SecretToken: "secret_1", MaxConnections: 3}, + webhookFound: true, + } + gateway := &recordingWebhookGateway{fakeBotAPIGateway: base} + d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)} + d.deliver(context.Background(), base.webhook) + + mu.Lock() + count := len(received) + mu.Unlock() + if count != 3 || base.webhookConfirmed != 13 { + t.Fatalf("received=%v confirmed=%d", received, base.webhookConfirmed) + } + gateway.mu.Lock() + successNext, failure := gateway.successNext, gateway.failure + gateway.mu.Unlock() + if !successNext.After(time.Now().Add(30*time.Minute)) || failure != "" { + t.Fatalf("success next=%v failure=%q", successNext, failure) + } +} + +func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var update struct { + UpdateID int `json:"update_id"` + } + _ = json.NewDecoder(r.Body).Decode(&update) + if update.UpdateID == 22 { + http.Error(w, "retry", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + base := &fakeBotAPIGateway{ + updates: webhookEvents(21, 22, 23), + webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, MaxConnections: 3}, + webhookFound: true, + } + gateway := &recordingWebhookGateway{fakeBotAPIGateway: base} + d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)} + d.deliver(context.Background(), base.webhook) + + gateway.mu.Lock() + failure, retryAt := gateway.failure, gateway.failureNext + gateway.mu.Unlock() + if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) { + t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt) + } +} diff --git a/internal/branding/branding.go b/internal/branding/branding.go new file mode 100644 index 00000000..2fb26671 --- /dev/null +++ b/internal/branding/branding.go @@ -0,0 +1,95 @@ +// Package branding owns the user-visible telesrv product identity. +// +// Protocol identifiers, client detection tokens and third-party compatibility +// headers do not belong here: callers must only pass text that is rendered to +// an end user. +package branding + +import ( + "net/url" + "regexp" + "strings" +) + +const ( + ProductName = "Telesrv" + ProductUsername = "telesrv" + DesktopAppName = "Telesrv Desktop" + AndroidAppName = "Telesrv Android" + IOSAppName = "Telesrv iOS" + MacOSAppName = "Telesrv macOS" + WebAAppName = "Telesrv Web A" + WebKAppName = "Telesrv Web K" + PremiumName = "Telesrv Premium" + StarsName = "Telesrv Stars" + DefaultPublicURL = "https://telesrv.net" +) + +// ClientAppName returns the branded display name for a stored client platform. +// Stored detection tokens remain unchanged; this is only used at presentation +// boundaries such as account.getAuthorizations. +func ClientAppName(platform string) string { + switch strings.ToLower(strings.TrimSpace(platform)) { + case "android": + return AndroidAppName + case "ios": + return IOSAppName + case "macos": + return MacOSAppName + case "telegram-tt", "weba": + return WebAAppName + case "tweb", "webk": + return WebKAppName + case "tdesktop", "desktop", "windows": + return DesktopAppName + default: + return ProductName + } +} + +// UserVisibleClientPlatform hides internal compatibility tokens from the +// authorization UI without changing their durable representation. +func UserVisibleClientPlatform(platform string) string { + if strings.EqualFold(strings.TrimSpace(platform), "telegram-tt") { + return "weba" + } + return UserVisibleText(platform, "") +} + +var ( + officialHTTPHostRE = regexp.MustCompile(`(?i)https?://(?:[a-z0-9-]+\.)*(?:telegram\.(?:org|me|com|dog)|t\.me)([^a-z0-9]|$)`) + officialBareHostRE = regexp.MustCompile(`(?i)(?:(?:[a-z0-9-]+\.)*telegram\.(?:org|me|com|dog)|\bt\.me)([^a-z0-9]|$)`) + officialBrandRE = regexp.MustCompile(`(?i)telegram|телеграм[\p{L}]*|تيليجرام|تلگرام|텔레그램|טלגרם`) + technicalIDRE = regexp.MustCompile(`^[A-Za-z0-9-]+(?:[._][A-Za-z0-9-]+)+$`) +) + +// UserVisibleText replaces the official product brand and its public hosts in +// text returned to clients. Placeholder syntax, markup and string keys are +// deliberately untouched by callers; only values should pass through here. +func UserVisibleText(value, publicBaseURL string) string { + if value == "" { + return "" + } + baseURL, publicHost := publicDestination(publicBaseURL) + value = officialHTTPHostRE.ReplaceAllString(value, baseURL+"${1}") + value = officialBareHostRE.ReplaceAllString(value, publicHost+"${1}") + // Some platform packs carry dotted or underscored runtime identifiers as + // values. They are not copy and changing them can break client navigation. + if technicalIDRE.MatchString(value) { + return value + } + return officialBrandRE.ReplaceAllString(value, ProductName) +} + +func publicDestination(raw string) (string, string) { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + if raw == "" { + raw = DefaultPublicURL + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" { + raw = DefaultPublicURL + parsed, _ = url.Parse(raw) + } + return raw, parsed.Host +} diff --git a/internal/branding/branding_test.go b/internal/branding/branding_test.go new file mode 100644 index 00000000..36c983c2 --- /dev/null +++ b/internal/branding/branding_test.go @@ -0,0 +1,64 @@ +package branding + +import "testing" + +func TestUserVisibleTextRebrandsWordsAndOfficialHosts(t *testing.T) { + got := UserVisibleText( + "Telegram telegram TELEGRAM Telegram-like https://translations.telegram.org/en t.me/example desktop.telegram.org", + "https://chat.example/root/", + ) + want := "Telesrv Telesrv Telesrv Telesrv-like https://chat.example/root/en chat.example/example chat.example" + if got != want { + t.Fatalf("UserVisibleText() = %q, want %q", got, want) + } +} + +func TestUserVisibleTextPreservesTechnicalIdentifiers(t *testing.T) { + for _, value := range []string{ + "org.telegram.messenger", + "telegram_antispam_user_id", + "telegram_aicomposetone", + } { + if got := UserVisibleText(value, ""); got != value { + t.Fatalf("UserVisibleText(%q) = %q, want unchanged", value, got) + } + } +} + +func TestUserVisibleTextRebrandsBareOfficialHostsWithoutTouchingDottedIdentifiers(t *testing.T) { + for input, want := range map[string]string{ + "telegram.org": "telesrv.net", + "desktop.telegram.org": "telesrv.net", + "t.me/example": "telesrv.net/example", + "org.telegram.messenger": "org.telegram.messenger", + } { + if got := UserVisibleText(input, ""); got != want { + t.Fatalf("UserVisibleText(%q) = %q, want %q", input, got, want) + } + } +} + +func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) { + got := UserVisibleText("Телеграмом تيليجرام تلگرام 텔레그램 טלגרם", "") + if want := "Telesrv Telesrv Telesrv Telesrv Telesrv"; got != want { + t.Fatalf("UserVisibleText() = %q, want %q", got, want) + } +} + +func TestClientPresentationNames(t *testing.T) { + for platform, want := range map[string]string{ + "tdesktop": DesktopAppName, + "android": AndroidAppName, + "ios": IOSAppName, + "macos": MacOSAppName, + "telegram-tt": WebAAppName, + "tweb": WebKAppName, + } { + if got := ClientAppName(platform); got != want { + t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want) + } + } + if got := UserVisibleClientPlatform("telegram-tt"); got != "weba" { + t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 546bd18f..cf4ff21a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,6 +196,11 @@ type Config struct { WebPagePreviewRatePerMin int // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 LangPackSeedDir string + // OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。 + OfficialGiftsDir string + // StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。 + // 该账本只用于自建服务端礼物链路,不连接任何外部区块链。 + StarGiftTONStartingGrant int64 // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 BlobDir string // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。 @@ -336,6 +341,21 @@ type Config struct { PremiumSweepInterval time.Duration // PremiumSweepBatch 是单次到期清理的最大行数。 PremiumSweepBatch int + // StarGiftSweepInterval drives offer expiry/refunds, auction rounds and their + // durable notification/delivery outboxes. It is entirely server-local. + StarGiftSweepInterval time.Duration + // StarGiftSweepBatch bounds rows/aggregates claimed by one sweep. + StarGiftSweepBatch int + StarGiftTransferStars int64 + StarGiftDropOriginalDetailsStars int64 + StarGiftOfferMinStars int + StarGiftStarsProceedsPermille int + StarGiftTONProceedsPermille int + StarGiftExportDelay time.Duration + StarGiftTransferDelay time.Duration + StarGiftResellDelay time.Duration + StarGiftCraftDelay time.Duration + StarGiftCraftChancePermille int // GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态 // 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。 @@ -529,6 +549,8 @@ func Load() (Config, error) { SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))), SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second), LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), + OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"), + StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000), BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300), @@ -593,12 +615,24 @@ func Load() (Config, error) { CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50), CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second), - PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), - PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), - PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), - StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), - PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), - PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), + PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), + PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), + PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), + StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), + PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), + PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), + StarGiftSweepInterval: envDurationOr("TELESRV_STARGIFT_SWEEP_INTERVAL", 15*time.Second), + StarGiftSweepBatch: envIntOr("TELESRV_STARGIFT_SWEEP_BATCH", 1000), + StarGiftTransferStars: int64(envIntOr("TELESRV_STARGIFT_TRANSFER_STARS", 25)), + StarGiftDropOriginalDetailsStars: int64(envIntOr("TELESRV_STARGIFT_DROP_DETAILS_STARS", 25)), + StarGiftOfferMinStars: envIntOr("TELESRV_STARGIFT_OFFER_MIN_STARS", 1), + StarGiftStarsProceedsPermille: envIntOr("TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE", 1000), + StarGiftTONProceedsPermille: envIntOr("TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE", 1000), + StarGiftExportDelay: envDurationOr("TELESRV_STARGIFT_EXPORT_DELAY", 0), + StarGiftTransferDelay: envDurationOr("TELESRV_STARGIFT_TRANSFER_DELAY", 0), + StarGiftResellDelay: envDurationOr("TELESRV_STARGIFT_RESELL_DELAY", 0), + StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0), + StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250), GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second), GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second), @@ -630,9 +664,40 @@ func Load() (Config, error) { if err := validateRPCResultCacheConfig(cfg); err != nil { return Config{}, err } + if err := validateStarGiftConfig(cfg); err != nil { + return Config{}, err + } return cfg, nil } +func validateStarGiftConfig(cfg Config) error { + if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 { + return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000") + } + if cfg.StarGiftTONStartingGrant < 0 { + return fmt.Errorf("TELESRV_STARGIFT_TON_STARTING_GRANT must be non-negative") + } + if cfg.StarGiftTransferStars < 0 || cfg.StarGiftDropOriginalDetailsStars < 0 || cfg.StarGiftOfferMinStars < 0 { + return fmt.Errorf("TELESRV_STARGIFT_TRANSFER_STARS, TELESRV_STARGIFT_DROP_DETAILS_STARS and TELESRV_STARGIFT_OFFER_MIN_STARS must be non-negative") + } + if cfg.StarGiftExportDelay < 0 || cfg.StarGiftTransferDelay < 0 || cfg.StarGiftResellDelay < 0 || cfg.StarGiftCraftDelay < 0 { + return fmt.Errorf("TELESRV_STARGIFT lifecycle delays must be non-negative") + } + const maxProtocolDelay = time.Duration(1<<31-1) * time.Second + if cfg.StarGiftExportDelay > maxProtocolDelay || cfg.StarGiftTransferDelay > maxProtocolDelay || + cfg.StarGiftResellDelay > maxProtocolDelay || cfg.StarGiftCraftDelay > maxProtocolDelay { + return fmt.Errorf("TELESRV_STARGIFT lifecycle delays exceed the protocol int32 date range") + } + if cfg.StarGiftCraftChancePermille < 0 || cfg.StarGiftCraftChancePermille > 1000 { + return fmt.Errorf("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE must be 0..1000") + } + if cfg.StarGiftStarsProceedsPermille < 0 || cfg.StarGiftStarsProceedsPermille > 1000 || + cfg.StarGiftTONProceedsPermille < 0 || cfg.StarGiftTONProceedsPermille > 1000 { + return fmt.Errorf("TELESRV_STARGIFT_*_PROCEEDS_PERMILLE must be 0..1000") + } + return nil +} + const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10)) func validateRPCResultCacheConfig(cfg Config) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 71b8741e..fe98e436 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -553,6 +553,19 @@ func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) { } } +func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) { + cfg := Config{ + StarGiftSweepInterval: time.Second, + StarGiftSweepBatch: 1, + StarGiftTONStartingGrant: -1, + StarGiftStarsProceedsPermille: 1000, + StarGiftTONProceedsPermille: 1000, + } + if err := validateStarGiftConfig(cfg); err == nil { + t.Fatal("negative internal TON starting grant was accepted") + } +} + func writeConfigFile(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o600); err != nil { diff --git a/internal/domain/account.go b/internal/domain/account.go index 96a63d4e..d247c57b 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings { } // DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。 -const DefaultAccountTTLDays = 365 +const ( + DefaultAccountTTLDays = 365 + // MaxAccountTTLDays prevents an untrusted int32 TL value from producing an + // out-of-range PostgreSQL interval/timestamp during deadline maintenance. + MaxAccountTTLDays = 3650 +) // GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。 // DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。 @@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings { // NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。 func (s AccountSettings) NormalizedTTLDays() int { - if s.AccountTTLDays <= 0 { + if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays { return DefaultAccountTTLDays } return s.AccountTTLDays diff --git a/internal/domain/account_deletion.go b/internal/domain/account_deletion.go new file mode 100644 index 00000000..282b8593 --- /dev/null +++ b/internal/domain/account_deletion.go @@ -0,0 +1,99 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrAccountDeleted = errors.New("account deleted") + ErrAccountDeletionForbidden = errors.New("account deletion forbidden") + ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid") + ErrAccountDeletionNotPending = errors.New("account deletion not pending") +) + +// AccountDeletionSource is the single audited reason attached to a user +// tombstone. Different entry points share one execution and cleanup path. +type AccountDeletionSource string + +const ( + AccountDeletionManual AccountDeletionSource = "manual" + AccountDeletionForgotPassword AccountDeletionSource = "forgot_password" + AccountDeletionTOSDecline AccountDeletionSource = "tos_decline" + AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry" + AccountDeletionAccountTTL AccountDeletionSource = "account_ttl" + AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry" +) + +type AccountDeletionRequestState string + +const ( + AccountDeletionPending AccountDeletionRequestState = "pending" + AccountDeletionCancelled AccountDeletionRequestState = "cancelled" + AccountDeletionExecuted AccountDeletionRequestState = "executed" +) + +// AccountDeletionRequest represents the seven-day 2FA confirmation window. +// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included +// in the durable service message and is never persisted as a credential. +type AccountDeletionRequest struct { + ID int64 + UserID int64 + RequesterAuthKeyID [8]byte + State AccountDeletionRequestState + Reason string + ConfirmHashDigest [32]byte + RequestedAt time.Time + ExecuteAt time.Time + CompletedAt time.Time +} + +type AccountDeletionSnapshot struct { + User User + HasPassword bool + PasswordUpdatedAt time.Time + Pending *AccountDeletionRequest +} + +type ScheduleAccountDeletion struct { + UserID int64 + RequesterAuthKeyID [8]byte + Reason string + ConfirmHashDigest [32]byte + ServiceMessage string + RequestedAt time.Time + ExecuteAt time.Time +} + +type AccountDeletionResult struct { + User User + Changed bool + RevokedAuthorizations []Authorization +} + +type AccountDeleteKind string + +const ( + AccountDeleteImmediate AccountDeleteKind = "immediate" + AccountDeleteDelayed AccountDeleteKind = "delayed" +) + +type AccountDeleteOutcome struct { + Kind AccountDeleteKind + WaitSeconds int + ExecuteAt time.Time + Deletion AccountDeletionResult +} + +type AccountDeletionCandidate struct { + UserID int64 + Source AccountDeletionSource + DueAt time.Time +} + +type AccountDeletionNotification struct { + ID int64 + TargetUserID int64 + DeletedUserID int64 + Attempts int +} diff --git a/internal/domain/bot.go b/internal/domain/bot.go index 3e2aedbd..2517ffc5 100644 --- a/internal/domain/bot.go +++ b/internal/domain/bot.go @@ -92,6 +92,7 @@ const ( type BotCommand struct { Command string `json:"command"` Description string `json:"description"` + Ephemeral bool `json:"ephemeral,omitempty"` } // BotMenuButtonType 标识菜单按钮类型。 @@ -204,15 +205,19 @@ type BotAttachMenuState struct { // BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。 type BotRequestedWebViewButton struct { - WebAppReqID string - BotUserID int64 - UserID int64 - ButtonID int - Text string - PeerType string - MaxQuantity int - CreatedAt time.Time - ExpiresAt time.Time + WebAppReqID string + BotUserID int64 + UserID int64 + ButtonID int + Text string + PeerType string + MaxQuantity int + PeerFilter *BotRequestPeerFilter + NameRequested bool + UsernameRequested bool + PhotoRequested bool + CreatedAt time.Time + ExpiresAt time.Time } // BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答 diff --git a/internal/domain/botapi_update.go b/internal/domain/botapi_update.go index 08b8cc07..9f2599c4 100644 --- a/internal/domain/botapi_update.go +++ b/internal/domain/botapi_update.go @@ -1,13 +1,142 @@ package domain +import "time" + // BotAPIUpdateKind is the Bot API delivery shape for a queued update. type BotAPIUpdateKind string const ( BotAPIUpdateMessage BotAPIUpdateKind = "message" BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message" + BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query" ) +// BotCallbackQuery is the protocol-neutral payload shared by MTProto +// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection. +type BotCallbackQuery struct { + ID int64 + BotUserID int64 + UserID int64 + Peer Peer + MessageID int + ChatInstance int64 + Data []byte + InlineMessage *BotInlineMessageID +} + +// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot. +// Ordinary queued messages are reloaded from their durable message tables; +// ephemeral messages have no such table and therefore travel in this explicit +// envelope instead of overloading SourcePts or an ordinary message id. The +// public shape deliberately cannot represent random IDs, payload hashes, +// auth-key/session identifiers, or the originating device. +type BotAPIEphemeralPayload struct { + Message BotAPIEphemeralMessage + ReplyTo *BotAPIEphemeralMessage `json:",omitempty"` +} + +type BotAPIEphemeralMessage struct { + ID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + Version uint64 + ExpiresAt time.Time +} + +func NewBotAPIEphemeralPayload(message EphemeralMessage) *BotAPIEphemeralPayload { + payload := &BotAPIEphemeralPayload{Message: publicBotAPIEphemeralMessage(message)} + if message.BotAPIReply != nil { + reply := publicBotAPIEphemeralMessage(*message.BotAPIReply) + payload.ReplyTo = &reply + } + return payload +} + +func publicBotAPIEphemeralMessage(message EphemeralMessage) BotAPIEphemeralMessage { + return BotAPIEphemeralMessage{ + ID: message.ID, Peer: message.Peer, + SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID, + Date: message.Date, EditDate: message.EditDate, + TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID, + Content: message.Content, Version: message.Version, ExpiresAt: message.ExpiresAt, + } +} + +func (m BotAPIEphemeralMessage) EphemeralMessage() EphemeralMessage { + return EphemeralMessage{ + ID: m.ID, Peer: m.Peer, + SenderUserID: m.SenderUserID, ReceiverUserID: m.ReceiverUserID, + Date: m.Date, EditDate: m.EditDate, + TopMessageID: m.TopMessageID, ReplyToEphemeralID: m.ReplyToEphemeralID, + Content: m.Content, Version: m.Version, ExpiresAt: m.ExpiresAt, + } +} + +func (p BotAPIEphemeralPayload) EphemeralMessage() EphemeralMessage { + message := p.Message.EphemeralMessage() + if p.ReplyTo != nil { + reply := p.ReplyTo.EphemeralMessage() + message.BotAPIReply = &reply + } + return message +} + +func (p BotAPIEphemeralPayload) Validate() error { + if err := p.Message.Validate(); err != nil { + return err + } + if p.Message.ReplyToEphemeralID == 0 { + if p.ReplyTo != nil { + return ErrEphemeralInvalid + } + return nil + } + if p.ReplyTo == nil || p.ReplyTo.Validate() != nil || p.ReplyTo.ID != p.Message.ReplyToEphemeralID || + p.ReplyTo.Peer != p.Message.Peer || p.ReplyTo.Date > p.Message.Date || + !sameEphemeralParticipantPair(p.Message.SenderUserID, p.Message.ReceiverUserID, p.ReplyTo.SenderUserID, p.ReplyTo.ReceiverUserID) { + return ErrEphemeralInvalid + } + return nil +} + +func sameEphemeralParticipantPair(firstSender, firstReceiver, secondSender, secondReceiver int64) bool { + return (firstSender == secondSender && firstReceiver == secondReceiver) || + (firstSender == secondReceiver && firstReceiver == secondSender) +} + +func (m BotAPIEphemeralMessage) Expired(now time.Time) bool { + return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt) +} + +func (m BotAPIEphemeralMessage) Validate() error { + date := time.Unix(int64(m.Date), 0) + if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 || + m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID || + m.Date <= 0 || m.Version == 0 || m.ExpiresAt.IsZero() || !m.ExpiresAt.After(date) || + m.ExpiresAt.Sub(date) > EphemeralMessageRetention+time.Second || + (m.EditDate != 0 && m.EditDate < m.Date) || m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID || + m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID { + return ErrEphemeralInvalid + } + return ValidateEphemeralContent(m.Content) +} + +// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64. +// It can be projected both to MTProto and to Bot API's opaque +// inline_message_id without leaking tg types into the store boundary. +type BotInlineMessageID struct { + DCID int + OwnerID int64 + ID int + AccessHash int64 +} + // BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id // and is global across all bots, matching Telegram Bot API's monotonic offset // contract without reusing MTProto pts from user/channel logs. @@ -19,6 +148,8 @@ type BotAPIUpdate struct { MessageID int SourcePts int Date int + Callback *BotCallbackQuery + Ephemeral *BotAPIEphemeralPayload } // EnqueueBotAPIUpdateRequest describes a message-like update that should be @@ -30,4 +161,6 @@ type EnqueueBotAPIUpdateRequest struct { MessageID int SourcePts int Date int + Callback *BotCallbackQuery + Ephemeral *BotAPIEphemeralPayload } diff --git a/internal/domain/botapi_update_test.go b/internal/domain/botapi_update_test.go new file mode 100644 index 00000000..86d8eade --- /dev/null +++ b/internal/domain/botapi_update_test.go @@ -0,0 +1,41 @@ +package domain + +import ( + "bytes" + "encoding/json" + "testing" + "time" +) + +func TestBotAPIEphemeralPayloadCannotSerializePrivateRoutingState(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + reply := EphemeralMessage{ + ID: 16, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 3001, ReceiverUserID: 2001, Date: int(now.Unix()) - 1, + Content: EphemeralContent{Message: "prompt"}, Version: 1, ExpiresAt: now.Add(EphemeralMessageRetention), + } + payload := NewBotAPIEphemeralPayload(EphemeralMessage{ + ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), + RandomID: 99, ReplyToEphemeralID: reply.ID, Content: EphemeralContent{Message: "private"}, + OriginDevice: EphemeralDevice{UserID: 2001, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44}, + PayloadHash: [32]byte{5, 6, 7}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), BotAPIReply: &reply, + }) + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + for _, privateField := range [][]byte{ + []byte("RandomID"), []byte("OriginDevice"), []byte("BusinessAuthKeyID"), + []byte("SessionID"), []byte("PayloadHash"), []byte("CreatedAt"), + } { + if bytes.Contains(raw, privateField) { + t.Fatalf("durable Bot API envelope leaked %s: %s", privateField, raw) + } + } + if payload.Validate() != nil || payload.Message.ID != 17 || payload.Message.Content.Message != "private" || payload.Message.ExpiresAt.IsZero() || + payload.ReplyTo == nil || payload.ReplyTo.ID != reply.ID { + t.Fatalf("public payload=%+v", payload) + } +} diff --git a/internal/domain/botapi_webhook.go b/internal/domain/botapi_webhook.go new file mode 100644 index 00000000..150d041a --- /dev/null +++ b/internal/domain/botapi_webhook.go @@ -0,0 +1,21 @@ +package domain + +import "time" + +// BotAPIWebhook is durable delivery configuration and observable retry state. +// The token secret is never stored here: authentication remains owned by BotProfile. +type BotAPIWebhook struct { + BotUserID int64 + URL string + SecretToken string + MaxConnections int + AllowedUpdates []BotAPIUpdateKind + // AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty) + // setWebhook parameter from omission, which must preserve the previous + // getUpdates/setWebhook policy atomically at the store boundary. + AllowedUpdatesSet bool + FailureCount int + LastErrorDate int + LastErrorMessage string + NextAttemptAt time.Time +} diff --git a/internal/domain/branding_test.go b/internal/domain/branding_test.go new file mode 100644 index 00000000..a8c2d3f1 --- /dev/null +++ b/internal/domain/branding_test.go @@ -0,0 +1,20 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestServiceIdentityAndLoginMessageUseTelesrvBrand(t *testing.T) { + serviceUser := OfficialSystemUser() + if serviceUser.FirstName != "Telesrv" || serviceUser.Username != "telesrv" { + t.Fatalf("service user = %+v, want Telesrv identity", serviceUser) + } + message, err := OfficialLoginCodeMessage(42, "12345", 1) + if err != nil { + t.Fatalf("build login message: %v", err) + } + if !strings.Contains(message.Body, "Telesrv") || strings.Contains(strings.ToLower(message.Body), "telegram") { + t.Fatalf("login message exposes wrong brand: %q", message.Body) + } +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index da8aa622..5bc861da 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -188,20 +188,23 @@ const ( // ChannelAdminRights is a domain-only representation of Telegram admin rights. type ChannelAdminRights struct { - ChangeInfo bool - PostMessages bool - EditMessages bool - DeleteMessages bool - PostStories bool - EditStories bool - DeleteStories bool - BanUsers bool - InviteUsers bool - PinMessages bool - AddAdmins bool - ManageCall bool - Anonymous bool - ManageRanks bool + ChangeInfo bool + PostMessages bool + EditMessages bool + DeleteMessages bool + PostStories bool + EditStories bool + DeleteStories bool + BanUsers bool + InviteUsers bool + PinMessages bool + AddAdmins bool + ManageCall bool + ManageChat bool + ManageTopics bool + Anonymous bool + ManageRanks bool + ManageLinkedPeers bool // ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的 // 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。 ManageDirectMessages bool @@ -210,19 +213,22 @@ type ChannelAdminRights struct { // CreatorChannelAdminRights returns the full rights set clients expect on creator projections. func CreatorChannelAdminRights() ChannelAdminRights { return ChannelAdminRights{ - ChangeInfo: true, - PostMessages: true, - EditMessages: true, - DeleteMessages: true, - PostStories: true, - EditStories: true, - DeleteStories: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - AddAdmins: true, - ManageCall: true, - ManageRanks: true, + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + PostStories: true, + EditStories: true, + DeleteStories: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + ManageChat: true, + ManageTopics: true, + ManageRanks: true, + ManageLinkedPeers: true, } } @@ -274,7 +280,10 @@ type ChannelBannedRights struct { SendPlain bool EditRank bool SendReactions bool - UntilDate int + // ManageLinkedPeers is the Layer 228 default restriction used by Communities: + // true means only admins may add peers; false lets members submit requests. + ManageLinkedPeers bool + UntilDate int } // ChannelReactionPolicyType describes which reactions are allowed in a channel. @@ -425,6 +434,10 @@ type Channel struct { // megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。 HasLink bool LinkedChatID int64 + // LinkedCommunityID is the unique Community containing this group/channel. + // A channel can belong to at most one Community and Communities themselves + // are stored in a separate aggregate, never in channels. + LinkedCommunityID int64 // Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。 // LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。 Monoforum bool @@ -550,13 +563,20 @@ const ( ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price" // ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。 ChannelActionStarGift ChannelMessageActionType = "star_gift" + // ChannelActionStarGiftUnique 映射 messageActionStarGiftUnique:collectible + // 升级、转赠等所有权变更只进入 Recent Actions,不伪造频道历史/pts。 + ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique" // ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。 ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper" + // ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero + // CommunityID means linked; zero means unlinked. + ChannelActionChangeCommunity ChannelMessageActionType = "change_community" ) // ChannelMessageAction describes a service action without depending on tg.*. type ChannelMessageAction struct { Type ChannelMessageActionType + CommunityID int64 Title string IconColor int IconEmojiID int64 @@ -583,7 +603,8 @@ type ChannelMessageAction struct { Incompleted []int TodoItems []MessageTodoItem // StarGift 仅 star_gift 服务消息使用。 - StarGift *MessageStarGiftAction + StarGift *MessageStarGiftAction + StarGiftUnique *MessageStarGiftUniqueAction // Wallpaper 仅 set_chat_wallpaper 服务消息使用。 Wallpaper *Wallpaper // Photo 仅 chat_edit_photo 服务消息使用。 @@ -599,17 +620,21 @@ type ChannelMessage struct { From Peer SendAs *Peer // SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。 - SavedPeer Peer - Date int - EditDate int - Post bool - Silent bool - NoForwards bool - Body string - Entities []MessageEntity - ReplyTo *MessageReply - Forward *MessageForward - ViaBotID int64 + SavedPeer Peer + // SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。 + SuggestedPost *SuggestedPost + // PaidMessageStars 是本条频道私信实际扣除的 Stars;管理员免费回复及普通频道消息为 0。 + PaidMessageStars int64 + Date int + EditDate int + Post bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + ViaBotID int64 // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 GroupedID int64 ReplyMarkup *MessageReplyMarkup @@ -1455,7 +1480,15 @@ type SendMonoforumMessageRequest struct { IdempotencyPreflighted bool Message string Entities []MessageEntity - Date int + Media *MessageMedia + ReplyTo *MessageReply + Silent bool + NoForwards bool + SuggestedPost *SuggestedPost + // AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。 + AllowPaidStars int64 + ClearDraft bool + Date int } // ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one @@ -1575,6 +1608,8 @@ type SendChannelMessageResult struct { Event ChannelUpdateEvent Recipients []int64 Duplicate bool + // SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。 + SenderStarsBalance *StarsBalance // ReplayDeleteEvent is the existing durable channel delete event paired // with a deleted exact-random_id replay. It must be returned only to the // caller echo and must never be fanned out as a fresh event. @@ -2015,18 +2050,24 @@ type ChannelSearchPostsRequest struct { // ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page // over channel/supergroup messages visible to the current account. type ChannelGlobalSearchRequest struct { - Query string - BroadcastsOnly bool - GroupsOnly bool - MusicOnly bool - HasFolderID bool - FolderID int - OffsetRate int - OffsetChannelID int64 - OffsetID int - MinDate int - MaxDate int - Limit int + Query string + ChannelIDs []int64 + RestrictChannelIDs bool + // AllowPublicPreview includes linked public channels that the account can + // preview without joining. It is enabled only by Layer 228 Community-scoped + // search; ordinary global search remains joined-dialog-only. + AllowPublicPreview bool + BroadcastsOnly bool + GroupsOnly bool + MusicOnly bool + HasFolderID bool + FolderID int + OffsetRate int + OffsetChannelID int64 + OffsetID int + MinDate int + MaxDate int + Limit int } // ChannelRepliesFilter describes messages.getReplies query conditions. diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go index 9ece8f49..92c2f2d0 100644 --- a/internal/domain/channel_errors.go +++ b/internal/domain/channel_errors.go @@ -6,37 +6,38 @@ import ( ) var ( - ErrChannelInvalid = errors.New("channel invalid") - ErrChannelPrivate = errors.New("channel private") - ErrChannelTitleInvalid = errors.New("channel title invalid") - ErrChannelUserBanned = errors.New("user banned in channel") - ErrChannelWriteForbidden = errors.New("chat write forbidden") - ErrChannelAdminRequired = errors.New("chat admin required") - ErrChannelNotModified = errors.New("chat not modified") - ErrChannelForumMissing = errors.New("channel forum missing") - ErrLinkNotModified = errors.New("discussion link not modified") - ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed") - ErrBroadcastIDInvalid = errors.New("broadcast id invalid") - ErrMegagroupIDInvalid = errors.New("megagroup id invalid") - ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden") - ErrChatPublicRequired = errors.New("chat public required") - ErrChannelUserCreator = errors.New("channel user creator") - ErrChannelRightForbidden = errors.New("channel right forbidden") - ErrPersistentTimestamp = errors.New("persistent timestamp invalid") - ErrInviteHashEmpty = errors.New("invite hash empty") - ErrInviteHashInvalid = errors.New("invite hash invalid") - ErrInviteHashExpired = errors.New("invite hash expired") - ErrInvitePermanent = errors.New("chat invite permanent") - ErrInviteRevokedMissing = errors.New("invite revoked missing") - ErrInviteRequestSent = errors.New("invite request sent") - ErrHideRequesterMissing = errors.New("hide requester missing") - ErrUsersTooMuch = errors.New("users too much") - ErrUserAlreadyParticipant = errors.New("user already participant") - ErrUserKicked = errors.New("user kicked") - ErrUserNotParticipant = errors.New("user not participant") - ErrBotGroupsBlocked = errors.New("bot groups blocked") - ErrReactionInvalid = errors.New("reaction invalid") - ErrReactionsTooMany = errors.New("reactions too many") + ErrChannelInvalid = errors.New("channel invalid") + ErrChannelPrivate = errors.New("channel private") + ErrChannelTitleInvalid = errors.New("channel title invalid") + ErrChannelUserBanned = errors.New("user banned in channel") + ErrChannelWriteForbidden = errors.New("chat write forbidden") + ErrChannelAdminRequired = errors.New("chat admin required") + ErrChannelNotModified = errors.New("chat not modified") + ErrChannelForumMissing = errors.New("channel forum missing") + ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported") + ErrLinkNotModified = errors.New("discussion link not modified") + ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed") + ErrBroadcastIDInvalid = errors.New("broadcast id invalid") + ErrMegagroupIDInvalid = errors.New("megagroup id invalid") + ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden") + ErrChatPublicRequired = errors.New("chat public required") + ErrChannelUserCreator = errors.New("channel user creator") + ErrChannelRightForbidden = errors.New("channel right forbidden") + ErrPersistentTimestamp = errors.New("persistent timestamp invalid") + ErrInviteHashEmpty = errors.New("invite hash empty") + ErrInviteHashInvalid = errors.New("invite hash invalid") + ErrInviteHashExpired = errors.New("invite hash expired") + ErrInvitePermanent = errors.New("chat invite permanent") + ErrInviteRevokedMissing = errors.New("invite revoked missing") + ErrInviteRequestSent = errors.New("invite request sent") + ErrHideRequesterMissing = errors.New("hide requester missing") + ErrUsersTooMuch = errors.New("users too much") + ErrUserAlreadyParticipant = errors.New("user already participant") + ErrUserKicked = errors.New("user kicked") + ErrUserNotParticipant = errors.New("user not participant") + ErrBotGroupsBlocked = errors.New("bot groups blocked") + ErrReactionInvalid = errors.New("reaction invalid") + ErrReactionsTooMany = errors.New("reactions too many") ) // SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. diff --git a/internal/domain/community.go b/internal/domain/community.go new file mode 100644 index 00000000..144222d0 --- /dev/null +++ b/internal/domain/community.go @@ -0,0 +1,214 @@ +package domain + +import "errors" + +const ( + MaxCommunityPeers = 100 + MaxCommunityBotPeers = 100 + MaxCommunityLinkRequests = 100 + MaxCommunityTitleRunes = 128 + MaxCommunityAboutRunes = 255 + MaxCommunityParticipants = 200 +) + +var ( + ErrCommunityInvalid = errors.New("community invalid") + ErrCommunityPrivate = errors.New("community private") + ErrCommunityAdminRequired = errors.New("community admin required") + ErrCommunityCreatorRequired = errors.New("community creator required") + ErrCommunityPeerInvalid = errors.New("community peer invalid") + ErrCommunityPeerLinked = errors.New("community peer already linked") + ErrCommunityPeersTooMuch = errors.New("community peers too much") + ErrCommunityRequestCreated = errors.New("community request created") + ErrCommunityRequestMissing = errors.New("community request missing") + ErrCommunityParticipantInvalid = errors.New("community participant invalid") +) + +// Community is the Layer 228 aggregation container. It intentionally has no +// message/read/pts fields: linked dialogs remain the only message truth. +type Community struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Date int + Deleted bool + DefaultBannedRights ChannelBannedRights + PhotoID int64 + PhotoDCID int + PhotoStripped []byte +} + +type CommunityMemberRole string + +const ( + CommunityRoleCreator CommunityMemberRole = "creator" + CommunityRoleAdmin CommunityMemberRole = "admin" + CommunityRoleMember CommunityMemberRole = "member" +) + +type CommunityMemberStatus string + +const ( + CommunityMemberActive CommunityMemberStatus = "active" + CommunityMemberKicked CommunityMemberStatus = "kicked" +) + +type CommunityMember struct { + CommunityID int64 + UserID int64 + Role CommunityMemberRole + Status CommunityMemberStatus + AdminRights ChannelAdminRights + Rank string + Date int +} + +func (m CommunityMember) Active() bool { return m.Status == CommunityMemberActive } + +func (m CommunityMember) CanManageLinkedPeers() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.ManageLinkedPeers)) +} + +func (m CommunityMember) CanChangeInfo() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.ChangeInfo)) +} + +func (m CommunityMember) CanAddAdmins() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.AddAdmins)) +} + +func (m CommunityMember) CanBanUsers() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.BanUsers)) +} + +type CommunityPeerVisibility string + +const ( + CommunityPeerVisible CommunityPeerVisibility = "visible" + CommunityPeerHidden CommunityPeerVisibility = "hidden" +) + +type CommunityPeerLink struct { + CommunityID int64 + Peer Peer + Visibility CommunityPeerVisibility + CanViewHistory bool + CreatedBy int64 + Date int +} + +func (l CommunityPeerLink) Visible() bool { return l.Visibility == CommunityPeerVisible } + +type CommunityPeerLinkRequest struct { + CommunityID int64 + Peer Peer + RequestedBy int64 + Visibility CommunityPeerVisibility + Date int +} + +type CommunityUserState struct { + CommunityID int64 + UserID int64 + Collapsed bool + Pinned bool + PinnedOrder int + NotifySettings *PeerNotifySettings +} + +type CommunityView struct { + Community Community + Self CommunityMember + State CommunityUserState + Links []CommunityPeerLink + Channels []Channel + Users []User + ServiceMessages []SendChannelMessageResult + AdminsCount int + KickedCount int + PendingRequests int + Forbidden bool +} + +func (v CommunityView) Creator() bool { + return v.Self.Active() && v.Self.Role == CommunityRoleCreator +} + +type CreateCommunityRequest struct { + CreatorUserID int64 + Title string + About string + InitialPeer Peer + Visibility CommunityPeerVisibility + Date int +} + +type CommunityTogglePeerLinkRequest struct { + ActorUserID int64 + CommunityID int64 + Peer Peer + Visibility CommunityPeerVisibility + Deleted bool + RequestOnly bool + Date int +} + +type CommunityTogglePeerLinkResult struct { + Community Community + Peer Peer + RequestedBy int64 + Link *CommunityPeerLink + ServiceMessage *SendChannelMessageResult + Removed bool + RequestCreated bool +} + +type CommunityPeerLinkRequestPage struct { + TotalCount int + Requests []CommunityPeerLinkRequest + NextOffset string + Channels []Channel + Users []User +} + +type CommunityParticipantJoinedChats struct { + CreatorChatIDs []int64 + JoinedChatIDs []int64 + Channels []Channel + Users []User +} + +type CommunityParticipantList struct { + Community Community + Count int + Participants []CommunityMember + Users []User + Hash int64 +} + +type CommunityParticipantBanResult struct { + Changed bool + ChannelBans []EditChannelBannedResult + RemovedLinks []CommunityTogglePeerLinkResult +} + +type CommunityEditAdminRequest struct { + ActorUserID int64 + CommunityID int64 + UserID int64 + Rights ChannelAdminRights + Rank string + Date int +} + +type CommunitySearchScope struct { + CommunityID int64 + ChannelIDs []int64 + BotUserIDs []int64 +} diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index 2347fa4d..96cc5a73 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -6,6 +6,9 @@ type PeerType string const ( PeerTypeUser PeerType = "user" PeerTypeChannel PeerType = "channel" + // PeerTypeCommunity identifies a Layer 228 Community container. Communities + // have dialog pin/notify state but never own messages, read boundaries or pts. + PeerTypeCommunity PeerType = "community" // PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder // (archive folder 行本身被置顶/取消置顶),ID 为 folder_id。 PeerTypeFolder PeerType = "folder" @@ -101,19 +104,42 @@ type DialogDraftWebPage struct { Optional bool } +type SuggestedPostPriceKind string + +const ( + SuggestedPostPriceStars SuggestedPostPriceKind = "stars" + SuggestedPostPriceTON SuggestedPostPriceKind = "ton" +) + +// SuggestedPostPrice is either a decimal Stars amount or a nanotons amount. +type SuggestedPostPrice struct { + Kind SuggestedPostPriceKind + Amount int64 + Nanos int +} + +// SuggestedPost is the domain-only snapshot shared by a monoforum message and its cloud draft. +type SuggestedPost struct { + Accepted bool + Rejected bool + Price *SuggestedPostPrice + ScheduleDate int +} + // DialogDraft is a cloud draft for one peer/topic, expressed only in domain types. type DialogDraft struct { - Peer Peer - TopMessageID int - Date int - NoWebpage bool - InvertMedia bool - Message string - Entities []MessageEntity - ReplyTo *MessageReply - WebPage *DialogDraftWebPage - Effect int64 - RichMessage *MessageRichMessage + Peer Peer + TopMessageID int + Date int + NoWebpage bool + InvertMedia bool + Message string + Entities []MessageEntity + ReplyTo *MessageReply + WebPage *DialogDraftWebPage + Effect int64 + SuggestedPost *SuggestedPost + RichMessage *MessageRichMessage } // Empty reports whether this draft should clear the cloud draft slot. @@ -126,6 +152,7 @@ func (d DialogDraft) Empty() bool { (d.ReplyTo == nil || replyOnlyTopic) && d.WebPage == nil && d.Effect == 0 && + d.SuggestedPost == nil && d.RichMessage.IsZero() } @@ -151,6 +178,7 @@ type DialogList struct { ChannelMessages []ChannelMessage Users []User Channels []Channel + Communities []CommunityView State UpdateState Hash int64 Count int diff --git a/internal/domain/ephemeral.go b/internal/domain/ephemeral.go new file mode 100644 index 00000000..8faa884e --- /dev/null +++ b/internal/domain/ephemeral.go @@ -0,0 +1,362 @@ +package domain + +import ( + "crypto/sha256" + "errors" + "time" + "unicode/utf8" +) + +const ( + // EphemeralMessageRetention matches TDesktop's in-memory upper bound. The + // server never replays these records; the retention only keeps callback, + // edit, delete and abuse-report lookups coherent across instances. + EphemeralMessageRetention = 48 * time.Hour + // EphemeralReplyWindow is the official Bot API eligible-action window. + EphemeralReplyWindow = 15 * time.Second + // MaxEphemeralCreateAttempts bounds random int32 ID collision retries. + MaxEphemeralCreateAttempts = 8 + // MaxEphemeralCallbackDataBytes is the Bot API callback_data wire limit. + MaxEphemeralCallbackDataBytes = 64 + // MaxEphemeralCaptionLength follows the Bot API media-caption contract. + MaxEphemeralCaptionLength = 1024 + // Rich messages are accepted at the domain boundary only within a bounded + // wire-sized snapshot. The current official client does not send this flag, + // but malformed callers must not be able to retain unbounded block vectors. + MaxEphemeralRichBlocksBytes = 1 << 20 + MaxEphemeralRichMediaRefs = 100 +) + +var ( + ErrEphemeralInvalid = errors.New("ephemeral message invalid") + ErrEphemeralNotFound = errors.New("ephemeral message not found") + ErrEphemeralExpired = errors.New("ephemeral message expired") + ErrEphemeralDeleted = errors.New("ephemeral message deleted") + ErrEphemeralIDCollision = errors.New("ephemeral message id collision") + ErrEphemeralRandomIDConflict = errors.New("ephemeral random id conflict") + ErrEphemeralVersionConflict = errors.New("ephemeral message version conflict") + ErrEphemeralReplyExpired = errors.New("ephemeral reply expired") + ErrEphemeralQueryInvalid = errors.New("ephemeral query invalid") + ErrEphemeralPeerInvalid = errors.New("ephemeral peer invalid") + ErrEphemeralSenderInvalid = errors.New("ephemeral sender invalid") + ErrEphemeralReceiverInvalid = errors.New("ephemeral receiver invalid") + ErrEphemeralCommandInvalid = errors.New("ephemeral command invalid") + ErrEphemeralForbidden = errors.New("ephemeral action forbidden") + ErrEphemeralDeviceMismatch = errors.New("ephemeral device mismatch") + ErrEphemeralCallbackInvalid = errors.New("ephemeral callback invalid") +) + +// EphemeralDevice identifies the exact client application that originated an +// eligible action. BusinessAuthKeyID is the durable device identity; SessionID +// is retained for binding checks and diagnostics, not used as a global key. +type EphemeralDevice struct { + UserID int64 + BusinessAuthKeyID [8]byte + SessionID int64 +} + +// EphemeralContent is the mutable presentation payload. Identity, routing and +// reply ancestry live on EphemeralMessage and never change during edits. +type EphemeralContent struct { + Message string + Entities []MessageEntity + Media *MessageMedia + ReplyMarkup *MessageReplyMarkup + RichMessage *MessageRichMessage +} + +// EphemeralMessage is a short-lived bot/member interaction. It deliberately +// has no ordinary message box ID, pts, qts, seq, unread or dialog fields. +type EphemeralMessage struct { + ID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + OriginDevice EphemeralDevice + PayloadHash [32]byte + Version uint64 + Deleted bool + CreatedAt time.Time + ExpiresAt time.Time + // BotAPIReply is a one-level, runtime-only reply snapshot. It is attached + // after the authoritative message has been written, excluded from Redis and + // broker JSON, and used only to project a valid Bot API reply_to_message. + BotAPIReply *EphemeralMessage `json:"-"` +} + +type SendClientEphemeralRequest struct { + SenderUserID int64 + ReceiverBotID int64 + Peer Peer + QueryID int64 + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + OriginDevice EphemeralDevice +} + +type SendBotEphemeralRequest struct { + BotUserID int64 + ReceiverUserID int64 + Peer Peer + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + // ActionMessageID authorizes the ordinary 15-second response path. When it + // is zero the bot must be an administrator and delivery targets every ready + // Layer 228 device of ReceiverUserID. + ActionMessageID int + // CallbackQueryID authorizes a response to a callback originating from a + // bot→user ephemeral message. The shared action record owns the target device. + CallbackQueryID int64 +} + +type EphemeralCallback struct { + Message EphemeralMessage + BotUserID int64 + UserID int64 + Peer Peer + Data []byte + Device EphemeralDevice + OccurredAt time.Time +} + +type EphemeralCallbackAction struct { + QueryID int64 + BotUserID int64 + UserID int64 + Peer Peer + MessageID int + TopMessageID int + Device EphemeralDevice + CreatedAt time.Time + ExpiresAt time.Time +} + +// EphemeralReportEvidence is the durable, device-identity-free snapshot kept +// for abuse review after the transient Redis record expires. It intentionally +// excludes OriginDevice, random IDs and session/auth-key identifiers. +type EphemeralReportEvidence struct { + MessageID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + PayloadHash [32]byte + Version uint64 +} + +// EphemeralAbuseReport is written only for a final report option. CommentHash +// makes retries idempotent without indexing potentially large user text. +type EphemeralAbuseReport struct { + ReporterUserID int64 + Option string + Comment string + CommentHash [32]byte + Evidence EphemeralReportEvidence + CreatedAt time.Time +} + +func NewEphemeralAbuseReport(reporterUserID int64, option, comment string, message EphemeralMessage, createdAt time.Time) EphemeralAbuseReport { + return EphemeralAbuseReport{ + ReporterUserID: reporterUserID, + Option: option, + Comment: comment, + CommentHash: sha256.Sum256([]byte(comment)), + Evidence: EphemeralReportEvidence{ + MessageID: message.ID, Peer: message.Peer, + SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID, + Date: message.Date, EditDate: message.EditDate, + TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID, + Content: message.Content, PayloadHash: message.PayloadHash, Version: message.Version, + }, + CreatedAt: createdAt, + } +} + +func (r EphemeralAbuseReport) Validate() error { + if r.ReporterUserID <= 0 || r.Option == "" || len(r.Option) > 64 || utf8.RuneCountInString(r.Comment) > 4096 || + r.Evidence.MessageID <= 0 || r.Evidence.MessageID > MaxMessageBoxID || + r.Evidence.Peer.Type != PeerTypeChannel || r.Evidence.Peer.ID <= 0 || + r.Evidence.SenderUserID <= 0 || r.Evidence.ReceiverUserID != r.ReporterUserID || + r.Evidence.SenderUserID == r.Evidence.ReceiverUserID || r.CreatedAt.IsZero() || + r.CommentHash != sha256.Sum256([]byte(r.Comment)) { + return ErrEphemeralInvalid + } + return nil +} + +type EditEphemeralFields struct { + SetMessage bool + Message string + Entities []MessageEntity + SetMedia bool + Media *MessageMedia + SetReplyMarkup bool + ReplyMarkup *MessageReplyMarkup +} + +type BotAPIFileInput struct { + LocationKey string + RemoteURL string + FileName string + MimeType string + Bytes []byte + Width int + Height int + Duration int + Title string + Performer string + Emoji string +} + +type BotAPIEphemeralSendInput struct { + BotUserID int64 + ChatID int64 + ReceiverUserID int64 + CallbackQueryID int64 + ReplyToEphemeralID int + TopMessageID int + Kind string + Text string + Entities []MessageEntity + ReplyMarkup *MessageReplyMarkup + File BotAPIFileInput + SecondaryFile BotAPIFileInput + DirectMedia *MessageMedia +} + +type BotAPIEphemeralEditInput struct { + BotUserID int64 + ChatID int64 + ReceiverUserID int64 + MessageID int + Mode EphemeralEditMode + Fields EditEphemeralFields + MediaKind string + File BotAPIFileInput + SecondaryFile BotAPIFileInput +} + +type EphemeralEditMode string + +const ( + EphemeralEditText EphemeralEditMode = "text" + EphemeralEditMedia EphemeralEditMode = "media" + EphemeralEditCaption EphemeralEditMode = "caption" + EphemeralEditReplyMarkup EphemeralEditMode = "reply_markup" +) + +func (m EphemeralMessage) ValidateForCreate(now time.Time) error { + if err := m.ValidateStored(); err != nil || m.Version != 1 || m.Deleted || !m.ExpiresAt.After(now) { + return ErrEphemeralInvalid + } + return nil +} + +func (m EphemeralMessage) ValidateStored() error { + if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 || + m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID || + m.RandomID == 0 || m.Date <= 0 || m.Version == 0 || m.CreatedAt.IsZero() || m.ExpiresAt.IsZero() || + !m.ExpiresAt.After(m.CreatedAt) || m.ExpiresAt.Sub(m.CreatedAt) > EphemeralMessageRetention || + m.Date != int(m.CreatedAt.Unix()) || (m.EditDate != 0 && m.EditDate < m.Date) || + m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID || + m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID || + m.PayloadHash == ([32]byte{}) { + return ErrEphemeralInvalid + } + zeroDevice := m.OriginDevice == (EphemeralDevice{}) + if !zeroDevice && (m.OriginDevice.UserID <= 0 || m.OriginDevice.BusinessAuthKeyID == ([8]byte{}) || + m.OriginDevice.SessionID == 0 || + (m.OriginDevice.UserID != m.SenderUserID && m.OriginDevice.UserID != m.ReceiverUserID)) { + return ErrEphemeralInvalid + } + if m.Deleted { + if m.Version < 2 || m.Content.Message != "" || len(m.Content.Entities) != 0 || m.Content.Media != nil || + m.Content.ReplyMarkup != nil || !m.Content.RichMessage.IsZero() { + return ErrEphemeralInvalid + } + return nil + } + return ValidateEphemeralContent(m.Content) +} + +func ValidateEphemeralContent(content EphemeralContent) error { + if !utf8.ValidString(content.Message) || utf8.RuneCountInString(content.Message) > MaxMessageTextLength || + len(content.Entities) > MaxMessageEntityCount || !validEphemeralEntityBounds(content.Message, content.Entities) { + return ErrEphemeralInvalid + } + if err := ValidateReplyMarkup(content.ReplyMarkup); err != nil { + return ErrEphemeralInvalid + } + if content.ReplyMarkup != nil && !content.ReplyMarkup.IsZero() && content.ReplyMarkup.Kind() != MessageReplyMarkupInline { + return ErrEphemeralInvalid + } + if content.Media != nil && !validEphemeralMedia(content.Media) { + return ErrEphemeralInvalid + } + if rich := content.RichMessage; !rich.IsZero() { + if len(rich.Blocks) == 0 || len(rich.Blocks) > MaxEphemeralRichBlocksBytes || + len(rich.Photos) > MaxEphemeralRichMediaRefs || len(rich.Documents) > MaxEphemeralRichMediaRefs { + return ErrEphemeralInvalid + } + } + if content.Message == "" && content.Media == nil && content.RichMessage.IsZero() { + return ErrEphemeralInvalid + } + return nil +} + +func validEphemeralEntityBounds(message string, entities []MessageEntity) bool { + utf16Length := 0 + for _, value := range message { + utf16Length++ + if value > 0xffff { + utf16Length++ + } + } + for _, entity := range entities { + if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || + entity.Length > utf16Length-entity.Offset { + return false + } + } + return true +} + +func validEphemeralMedia(media *MessageMedia) bool { + if media == nil || media.IsZero() || media.ServiceAction != nil || media.Dice != nil || media.Poll != nil || + media.GeoLive != nil || media.Todo != nil || media.Story != nil || media.WebPage != nil { + return false + } + switch media.Kind { + case MessageMediaKindPhoto: + return media.Photo != nil && media.Document == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindDocument: + return media.Document != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindContact: + return media.Contact != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindGeo: + return media.Geo != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Venue == nil + case MessageMediaKindVenue: + return media.Venue != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Geo == nil + default: + return false + } +} + +func (m EphemeralMessage) Expired(now time.Time) bool { + return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt) +} diff --git a/internal/domain/ephemeral_test.go b/internal/domain/ephemeral_test.go new file mode 100644 index 00000000..2c28cec5 --- /dev/null +++ b/internal/domain/ephemeral_test.go @@ -0,0 +1,64 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestValidateEphemeralContentBoundsAllRetainedVectors(t *testing.T) { + valid := EphemeralContent{ + Message: "hi 👋", + Entities: []MessageEntity{{Type: MessageEntityBold, Offset: 0, Length: 2}}, + ReplyMarkup: &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{ + Type: MarkupButtonCallback, Text: "OK", Data: []byte("ok"), + }}}}, + } + if err := ValidateEphemeralContent(valid); err != nil { + t.Fatalf("valid content: %v", err) + } + + badBounds := valid + badBounds.Entities = []MessageEntity{{Type: MessageEntityBold, Offset: 5, Length: 2}} + if err := ValidateEphemeralContent(badBounds); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("entity bounds err=%v", err) + } + badKeyboard := valid + badKeyboard.ReplyMarkup = &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{ + Type: MarkupButtonText, Text: "public keyboard", + }}}} + if err := ValidateEphemeralContent(badKeyboard); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("reply keyboard err=%v", err) + } + badRich := EphemeralContent{RichMessage: &MessageRichMessage{Blocks: make([]byte, MaxEphemeralRichBlocksBytes+1)}} + if err := ValidateEphemeralContent(badRich); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("rich bound err=%v", err) + } + badMedia := EphemeralContent{Media: &MessageMedia{Kind: MessageMediaKindPhoto}} + if err := ValidateEphemeralContent(badMedia); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("media shape err=%v", err) + } +} + +func TestEphemeralStoredStateRejectsPartialDeviceAndInvalidTombstone(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + message := EphemeralMessage{ + ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), RandomID: 9, + Content: EphemeralContent{Message: "private"}, OriginDevice: EphemeralDevice{UserID: 3001}, + PayloadHash: [32]byte{1}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), + } + if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("partial device err=%v", err) + } + message.OriginDevice = EphemeralDevice{} + message.Deleted = true + message.Content = EphemeralContent{} + if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("version-one tombstone err=%v", err) + } + message.Version = 2 + if err := message.ValidateStored(); err != nil { + t.Fatalf("valid tombstone err=%v", err) + } +} diff --git a/internal/domain/login_code_delivery.go b/internal/domain/login_code_delivery.go index d8f3a428..6f4ff134 100644 --- a/internal/domain/login_code_delivery.go +++ b/internal/domain/login_code_delivery.go @@ -4,11 +4,13 @@ import ( "fmt" "math" "strings" + + "telesrv/internal/branding" ) -const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! +const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `! -This code can be used to log in to your Telegram account. We never ask it for anything else. +This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else. If you didn't request this code by trying to log in on another device, simply ignore this message.` diff --git a/internal/domain/media.go b/internal/domain/media.go index 1df60ebf..8875ca39 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -564,7 +564,9 @@ const ( // MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The // immutable collectible snapshot is carried by the service message so an // exact replay/difference never depends on mutable catalog state. - MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique" + MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique" + MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer" + MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined" ) // MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。 @@ -608,78 +610,132 @@ type MessageWebViewDataAction struct { type MessageRequestedPeerAction struct { ButtonID int `json:"button_id"` Peers []Peer `json:"peers"` + // Details is the immutable, permission-gated snapshot delivered to the bot. + // It is kept separate from Peers because the sender-side MTProto action only + // exposes peer identities, while the bot-side/Bot API view may additionally + // expose the requested name, username, and profile photo. + Details []MessageRequestedPeerDetails `json:"details,omitempty"` + NameRequested bool `json:"name_requested,omitempty"` + UsernameRequested bool `json:"username_requested,omitempty"` + PhotoRequested bool `json:"photo_requested,omitempty"` +} + +type MessageRequestedPeerDetails struct { + Peer Peer `json:"peer"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Photo *Photo `json:"photo,omitempty"` } // MessageServiceAction 是私聊服务消息动作的协议中立表示。 type MessageServiceAction struct { - Kind MessageServiceActionKind `json:"kind"` - Photo *Photo `json:"photo,omitempty"` - Call *MessagePhoneCallAction `json:"call,omitempty"` - ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"` - BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` - WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` - RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` - ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` - StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` - StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"` + Kind MessageServiceActionKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + Call *MessagePhoneCallAction `json:"call,omitempty"` + ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"` + BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` + WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` + RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` + ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` + StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` + StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"` + StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"` + StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"` } // MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价) // 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。 type MessageStarGiftAction struct { - GiftID int64 `json:"gift_id"` - Stars int64 `json:"stars"` - ConvertStars int64 `json:"convert_stars,omitempty"` - Title string `json:"title,omitempty"` - Sticker *Document `json:"sticker,omitempty"` - Message string `json:"message,omitempty"` - FromUserID int64 `json:"from_user_id,omitempty"` - PeerUserID int64 `json:"peer_user_id,omitempty"` - PeerChannelID int64 `json:"peer_channel_id,omitempty"` - SavedID int64 `json:"saved_id,omitempty"` - NameHidden bool `json:"name_hidden,omitempty"` - Saved bool `json:"saved,omitempty"` - Converted bool `json:"converted,omitempty"` - CanUpgrade bool `json:"can_upgrade,omitempty"` - PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` - UpgradeStars int64 `json:"upgrade_stars,omitempty"` - UpgradeMsgID int `json:"upgrade_msg_id,omitempty"` + GiftID int64 `json:"gift_id"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars,omitempty"` + Title string `json:"title,omitempty"` + Sticker *Document `json:"sticker,omitempty"` + Message string `json:"message,omitempty"` + FromUserID int64 `json:"from_user_id,omitempty"` + PeerUserID int64 `json:"peer_user_id,omitempty"` + PeerChannelID int64 `json:"peer_channel_id,omitempty"` + SavedID int64 `json:"saved_id,omitempty"` + NameHidden bool `json:"name_hidden,omitempty"` + Saved bool `json:"saved,omitempty"` + Converted bool `json:"converted,omitempty"` + CanUpgrade bool `json:"can_upgrade,omitempty"` + PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"` + UpgradeSeparate bool `json:"upgrade_separate,omitempty"` + // UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and + // is the price of a normal paid upgrade. UpgradeStars below belongs to the + // outer messageActionStarGift and is only the amount already prepaid by the + // sender. TDesktop uses these two fields to choose the paid vs free flow. + UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"` + UpgradeStars int64 `json:"upgrade_stars,omitempty"` + UpgradeMsgID int `json:"upgrade_msg_id,omitempty"` + GiftMsgID int `json:"gift_msg_id,omitempty"` + GiftNum int `json:"gift_num,omitempty"` + AuctionAcquired bool `json:"auction_acquired,omitempty"` + To Peer `json:"to,omitempty"` } -// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade -// service message. Commercial transfer/resale/export fields are intentionally -// absent from the collectibles mainline. type MessageStarGiftUniqueAction struct { - Gift UniqueStarGift `json:"gift"` - FromUserID int64 `json:"from_user_id,omitempty"` - Peer Peer `json:"peer"` - SavedID int64 `json:"saved_id,omitempty"` - Upgrade bool `json:"upgrade,omitempty"` - Saved bool `json:"saved,omitempty"` - PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + Gift UniqueStarGift `json:"gift"` + FromUserID int64 `json:"from_user_id,omitempty"` + Peer Peer `json:"peer"` + SavedID int64 `json:"saved_id,omitempty"` + Upgrade bool `json:"upgrade,omitempty"` + Saved bool `json:"saved,omitempty"` + PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"` + Transferred bool `json:"transferred,omitempty"` + Refunded bool `json:"refunded,omitempty"` + Assigned bool `json:"assigned,omitempty"` + FromOffer bool `json:"from_offer,omitempty"` + Craft bool `json:"craft,omitempty"` + CanExportAt int `json:"can_export_at,omitempty"` + TransferStars int64 `json:"transfer_stars,omitempty"` + ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"` + CanTransferAt int `json:"can_transfer_at,omitempty"` + CanResellAt int `json:"can_resell_at,omitempty"` + DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"` + CanCraftAt int `json:"can_craft_at,omitempty"` +} + +type MessageStarGiftOfferAction struct { + Gift UniqueStarGift `json:"gift"` + Price StarGiftAmount `json:"price"` + ExpiresAt int `json:"expires_at"` + Accepted bool `json:"accepted,omitempty"` + Declined bool `json:"declined,omitempty"` +} + +type MessageStarGiftOfferDeclinedAction struct { + Gift UniqueStarGift `json:"gift"` + Price StarGiftAmount `json:"price"` + Expired bool `json:"expired,omitempty"` } // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 type MessageMedia struct { - Kind MessageMediaKind `json:"kind"` - Photo *Photo `json:"photo,omitempty"` - Document *Document `json:"document,omitempty"` - Contact *MessageContact `json:"contact,omitempty"` - ServiceAction *MessageServiceAction `json:"service_action,omitempty"` - Geo *MessageGeoPoint `json:"geo,omitempty"` - Venue *MessageVenue `json:"venue,omitempty"` - Dice *MessageDice `json:"dice,omitempty"` - Poll *MessagePoll `json:"poll,omitempty"` - GeoLive *MessageGeoLive `json:"geo_live,omitempty"` - Todo *MessageTodo `json:"todo,omitempty"` - Story *MessageStory `json:"story,omitempty"` - WebPage *MessageWebPage `json:"web_page,omitempty"` - Spoiler bool `json:"spoiler,omitempty"` - TTLSeconds int `json:"ttl_seconds,omitempty"` - Nopremium bool `json:"nopremium,omitempty"` - Voice bool `json:"voice,omitempty"` - Round bool `json:"round,omitempty"` - Video bool `json:"video,omitempty"` + Kind MessageMediaKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + LivePhotoVideo *Document `json:"live_photo_video,omitempty"` + Document *Document `json:"document,omitempty"` + Contact *MessageContact `json:"contact,omitempty"` + ServiceAction *MessageServiceAction `json:"service_action,omitempty"` + Geo *MessageGeoPoint `json:"geo,omitempty"` + Venue *MessageVenue `json:"venue,omitempty"` + Dice *MessageDice `json:"dice,omitempty"` + Poll *MessagePoll `json:"poll,omitempty"` + GeoLive *MessageGeoLive `json:"geo_live,omitempty"` + Todo *MessageTodo `json:"todo,omitempty"` + Story *MessageStory `json:"story,omitempty"` + WebPage *MessageWebPage `json:"web_page,omitempty"` + Spoiler bool `json:"spoiler,omitempty"` + TTLSeconds int `json:"ttl_seconds,omitempty"` + Nopremium bool `json:"nopremium,omitempty"` + Voice bool `json:"voice,omitempty"` + Round bool `json:"round,omitempty"` + Video bool `json:"video,omitempty"` // InvertMedia 映射 message.invert_media:媒体(典型为链接预览)渲染在文本上方。 // 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。 InvertMedia bool `json:"invert_media,omitempty"` diff --git a/internal/domain/message.go b/internal/domain/message.go index c35efca7..679bd7d3 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -151,7 +151,7 @@ type Message struct { // (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。 // 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。 Effect int64 - // ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可 + // ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可 // 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。 ReplyMarkup *MessageReplyMarkup // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 @@ -242,6 +242,10 @@ type MessageFilter struct { // SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息 // (messages.getSavedHistory);Peer 必须同时是 self。 SavedPeer Peer + // PeerIDs restricts a global private search to these user peers. Empty is a + // valid restricted set, so RestrictPeerIDs carries presence separately. + PeerIDs []int64 + RestrictPeerIDs bool } // SendPrivateTextRequest 是私聊文本/媒体发送命令。 @@ -283,7 +287,7 @@ type SendPrivateTextRequest struct { // BusinessAutomationKind is internal app-layer metadata used to suppress // recursive greeting/away automation for server-generated replies. BusinessAutomationKind BusinessAutomationKind - // ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。 + // ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。 ReplyMarkup *MessageReplyMarkup // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 RichMessage *MessageRichMessage diff --git a/internal/domain/message_markup.go b/internal/domain/message_markup.go index d25093a9..35c992d9 100644 --- a/internal/domain/message_markup.go +++ b/internal/domain/message_markup.go @@ -18,6 +18,10 @@ const ( MaxCallbackDataLen = 64 // MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。 MaxMarkupButtonTextLen = 256 + // MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。 + MaxReplyKeyboardButtonTextLen = 64 + // MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。 + MaxReplyKeyboardPlaceholderLen = 64 // MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。 MaxBotCallbackAnswerLen = 200 // MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。 @@ -38,20 +42,81 @@ var ( ErrStartParamInvalid = errors.New("start param invalid") ) -// MarkupButtonType 标识 P3 支持的 inline 按钮类型。 +// MarkupButtonType 标识消息键盘按钮类型。 type MarkupButtonType string const ( + // MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。 + MarkupButtonText MarkupButtonType = "text" // MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。 MarkupButtonCallback MarkupButtonType = "callback" // MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。 - MarkupButtonURL MarkupButtonType = "url" + MarkupButtonURL MarkupButtonType = "url" + MarkupButtonRequestPhone MarkupButtonType = "request_phone" + MarkupButtonRequestLocation MarkupButtonType = "request_location" + MarkupButtonRequestPoll MarkupButtonType = "request_poll" + MarkupButtonRequestPeer MarkupButtonType = "request_peer" + MarkupButtonWebView MarkupButtonType = "webview" + MarkupButtonSimpleWebView MarkupButtonType = "simple_webview" + MarkupButtonSwitchInline MarkupButtonType = "switch_inline" + MarkupButtonCopy MarkupButtonType = "copy" ) -// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。 +// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram +// intentionally exposes semantic colors instead of arbitrary RGB values. +type MarkupButtonStyle string + +const ( + MarkupButtonStylePrimary MarkupButtonStyle = "primary" + MarkupButtonStyleDanger MarkupButtonStyle = "danger" + MarkupButtonStyleSuccess MarkupButtonStyle = "success" +) + +// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without +// importing protocol types into persisted message state. +type BotRequestAdminRights struct { + Anonymous bool `json:"anonymous,omitempty"` + ManageChat bool `json:"manage_chat,omitempty"` + DeleteMessages bool `json:"delete_messages,omitempty"` + ManageVideoChats bool `json:"manage_video_chats,omitempty"` + RestrictMembers bool `json:"restrict_members,omitempty"` + PromoteMembers bool `json:"promote_members,omitempty"` + ChangeInfo bool `json:"change_info,omitempty"` + InviteUsers bool `json:"invite_users,omitempty"` + PostStories bool `json:"post_stories,omitempty"` + EditStories bool `json:"edit_stories,omitempty"` + DeleteStories bool `json:"delete_stories,omitempty"` + PostMessages bool `json:"post_messages,omitempty"` + EditMessages bool `json:"edit_messages,omitempty"` + PinMessages bool `json:"pin_messages,omitempty"` + ManageTopics bool `json:"manage_topics,omitempty"` + ManageDirectMessages bool `json:"manage_direct_messages,omitempty"` +} + +type BotRequestPeerFilter struct { + UserIsBotSet bool `json:"user_is_bot_set,omitempty"` + UserIsBot bool `json:"user_is_bot,omitempty"` + UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"` + UserIsPremium bool `json:"user_is_premium,omitempty"` + ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"` + ChatHasUsername bool `json:"chat_has_username,omitempty"` + ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"` + ChatIsForum bool `json:"chat_is_forum,omitempty"` + ChatIsCreated bool `json:"chat_is_created,omitempty"` + BotIsMember bool `json:"bot_is_member,omitempty"` + UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"` + BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"` +} + +// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮; +// inline keyboard 当前接受 callback/url。 type MarkupButton struct { Type MarkupButtonType `json:"type"` Text string `json:"text"` + // Style is one of primary/danger/success. Empty means the client default. + Style MarkupButtonStyle `json:"style,omitempty"` + // IconCustomEmojiID is the optional custom emoji rendered before Text. + IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"` // Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64 // 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。 Data []byte `json:"data,omitempty"` @@ -60,11 +125,68 @@ type MarkupButton struct { // RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password, // 2FA SRP 校验 P3 stub)。 RequiresPassword bool `json:"requires_password,omitempty"` + // PollType is empty, "regular", or "quiz" for request_poll. + PollType string `json:"poll_type,omitempty"` + // ButtonID and request-peer fields preserve Bot API request_id and the + // client-side chooser shape. RequestPeerType is user/chat/broadcast. + ButtonID int `json:"button_id,omitempty"` + RequestPeerType string `json:"request_peer_type,omitempty"` + MaxQuantity int `json:"max_quantity,omitempty"` + NameRequested bool `json:"name_requested,omitempty"` + UsernameRequested bool `json:"username_requested,omitempty"` + PhotoRequested bool `json:"photo_requested,omitempty"` + RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"` + Query string `json:"query,omitempty"` + SamePeer bool `json:"same_peer,omitempty"` + PeerTypes []string `json:"peer_types,omitempty"` + CopyText string `json:"copy_text,omitempty"` } -// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。 +// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。 +type MessageReplyMarkupType string + +const ( + MessageReplyMarkupInline MessageReplyMarkupType = "inline" + MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard" + MessageReplyMarkupHide MessageReplyMarkupType = "hide" + MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply" +) + +// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline +// 非空表示 0110 之前已经持久化的合法 inline keyboard,Kind 会将其解释为 inline。 type MessageReplyMarkup struct { - Inline [][]MarkupButton `json:"inline,omitempty"` + Type MessageReplyMarkupType `json:"type,omitempty"` + Inline [][]MarkupButton `json:"inline,omitempty"` + Keyboard [][]MarkupButton `json:"keyboard,omitempty"` + Resize bool `json:"resize,omitempty"` + SingleUse bool `json:"single_use,omitempty"` + Selective bool `json:"selective,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Placeholder string `json:"placeholder,omitempty"` +} + +// Kind 返回 markup constructor;兼容已落库的无 Type inline 快照。 +func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType { + if m == nil { + return "" + } + if m.Type != "" { + return m.Type + } + if len(m.Inline) > 0 { + return MessageReplyMarkupInline + } + return "" +} + +// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。 +func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool { + switch m.Kind() { + case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply: + return true + default: + return false + } } // IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。 @@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool { if m == nil { return true } - for _, row := range m.Inline { - if len(row) > 0 { - return false + switch m.Kind() { + case MessageReplyMarkupInline: + for _, row := range m.Inline { + if len(row) > 0 { + return false + } } + return true + case MessageReplyMarkupKeyboard: + for _, row := range m.Keyboard { + if len(row) > 0 { + return false + } + } + return true + case MessageReplyMarkupHide, MessageReplyMarkupForceReply: + return false + default: + return true } - return true } -// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。 -// 空 markup 合法(视为清空/无键盘)。 +// ValidateReplyMarkup 校验 markup constructor、结构与按钮,校验须先于落库(I9)。 +// 空 inline markup 合法(视为清空/无键盘)。 func ValidateReplyMarkup(m *MessageReplyMarkup) error { if m == nil { return nil } - if len(m.Inline) > MaxMarkupRows { + kind := m.Kind() + if kind == "" { + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return nil + } + switch kind { + case MessageReplyMarkupInline: + if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return validateMarkupRows(m.Inline, false) + case MessageReplyMarkupKeyboard: + if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen { + return ErrButtonInvalid + } + if len(m.Keyboard) == 0 { + return ErrButtonInvalid + } + return validateMarkupRows(m.Keyboard, true) + case MessageReplyMarkupHide: + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return nil + case MessageReplyMarkupForceReply: + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen { + return ErrButtonInvalid + } + return nil + default: + return ErrButtonInvalid + } +} + +func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error { + if len(rows) > MaxMarkupRows { return ErrButtonInvalid } total := 0 - for _, row := range m.Inline { - if len(row) > MaxMarkupButtonsPerRow { + for _, row := range rows { + if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow { return ErrButtonInvalid } total += len(row) @@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error { return ErrButtonInvalid } for i := range row { - if err := validateMarkupButton(row[i]); err != nil { + if err := validateMarkupButton(row[i], replyKeyboard); err != nil { return err } } @@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error { return nil } -func validateMarkupButton(b MarkupButton) error { +func validateMarkupButton(b MarkupButton, replyKeyboard bool) error { text := strings.TrimSpace(b.Text) if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen { return ErrButtonInvalid } + switch b.Style { + case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess: + default: + return ErrButtonInvalid + } + if b.IconCustomEmojiID < 0 { + return ErrButtonInvalid + } + if replyKeyboard { + if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen { + return ErrButtonInvalid + } + switch b.Type { + case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation: + case MarkupButtonRequestPoll: + if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" { + return ErrButtonInvalid + } + case MarkupButtonRequestPeer: + if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 || + (b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") { + return ErrButtonInvalid + } + if b.RequestPeerFilter != nil { + filter := b.RequestPeerFilter + if b.RequestPeerType == "user" { + if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember || + filter.UserAdminRights != nil || filter.BotAdminRights != nil { + return ErrButtonInvalid + } + } else { + if filter.UserIsBotSet || filter.UserIsPremiumSet || + (b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) { + return ErrButtonInvalid + } + } + } + case MarkupButtonSimpleWebView: + if err := validateButtonURL(b.URL); err != nil { + return err + } + default: + return ErrButtonTypeInvalid + } + if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" { + return ErrButtonInvalid + } + return nil + } switch b.Type { case MarkupButtonCallback: if len(b.Data) > MaxCallbackDataLen { @@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error { if err := validateButtonURL(b.URL); err != nil { return err } + case MarkupButtonWebView: + if err := validateButtonURL(b.URL); err != nil { + return err + } + case MarkupButtonSwitchInline: + if utf8.RuneCountInString(b.Query) > 256 { + return ErrButtonInvalid + } + case MarkupButtonCopy: + if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 { + return ErrButtonInvalid + } default: // webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。 return ErrButtonTypeInvalid diff --git a/internal/domain/message_markup_test.go b/internal/domain/message_markup_test.go index d9d69ffb..38444c4d 100644 --- a/internal/domain/message_markup_test.go +++ b/internal/domain/message_markup_test.go @@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) { {"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid}, {"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid}, {"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid}, - {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid}, + {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid}, + {"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil}, + {"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil}, + {"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil}, + {"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid}, + {"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid}, + {"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid}, + {"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid}, + {"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid}, + {"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid}, + {"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil}, + {"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil}, + {"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid}, + {"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) { if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() { t.Fatal("markup with a button must not be zero") } + if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() { + t.Fatal("reply keyboard with a button must not be zero") + } + if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() { + t.Fatal("hide keyboard constructor must not be zero") + } } diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go index 3237d03f..21ea447b 100644 --- a/internal/domain/star_gift.go +++ b/internal/domain/star_gift.go @@ -24,29 +24,85 @@ type StarGift struct { UpgradeIssued int // 当前已发行数量 Title string // 可选标题 Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃) + + // Layer 228 regular-gift shape. Static release facts live in the immutable + // catalog revision; AvailabilityRemains/AvailabilityResale are the current + // inventory projection maintained on the catalog aggregate. + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityRemains int + AvailabilityTotal int + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + ReleasedBy Peer + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *StarGiftBackground +} + +// StarGiftBackground is the release-level palette used by auction cards and +// gift previews before a collectible backdrop is selected. +type StarGiftBackground struct { + CenterColor int + EdgeColor int + TextColor int } // SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。 type SavedStarGift struct { - ID int64 - Owner Peer // 收礼 peer(user/channel) - FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) - GiftID int64 // → StarGift.ID - RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影 - MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0 - SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0 - Date int // 收到时刻 Unix 秒 - NameHidden bool // 送礼人请求隐藏姓名 - Unsaved bool // 未展示在个人资料(saveStarGift 切换) - Converted bool // 已转换回 Stars(终态,从列表排除) - ConvertStars int64 // 转换可退回的 Stars - PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额 - Message string // 附言(可选) - UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥 - UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id - PinnedOrder int // >0 表示资料页置顶顺序 - CollectionIDs []int // 当前所属集合;按集合顺序稳定返回 - Unique *UniqueStarGift + ID int64 + Owner Peer // 收礼 peer(user/channel) + FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) + GiftID int64 // → StarGift.ID + RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影 + MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0 + SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0 + Date int // 收到时刻 Unix 秒 + NameHidden bool // 送礼人请求隐藏姓名 + Unsaved bool // 未展示在个人资料(saveStarGift 切换) + Converted bool // 已转换回 Stars(终态,从列表排除) + LifecycleStatus StarGiftLifecycleStatus + ConvertStars int64 // 转换可退回的 Stars + PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额 + PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement + GiftNum int // auction-acquired release number for regular gifts + Message string // 附言(可选) + UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥 + TransferStars int64 + CanExportAt int + CanTransferAt int + CanResellAt int + DropOriginalDetailsStars int64 + CanCraftAt int + UpgradeMsgID int // 当前 owner 侧承载 messageActionStarGiftUnique 的消息 id;所有权转移时随新消息更新 + PinnedOrder int // >0 表示资料页置顶顺序 + CollectionIDs []int // 当前所属集合;按集合顺序稳定返回 + Unique *UniqueStarGift +} + +type StarGiftLifecycleStatus string + +const ( + StarGiftLifecycleActive StarGiftLifecycleStatus = "active" + StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted" + StarGiftLifecycleBurned StarGiftLifecycleStatus = "burned" + StarGiftLifecycleExported StarGiftLifecycleStatus = "exported" +) + +func (s StarGiftLifecycleStatus) Live() bool { + return s == StarGiftLifecycleActive } // StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。 @@ -58,8 +114,31 @@ const ( StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop" ) -// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的 -// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。 +// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only +// kind eligible for a regular upgrade draw; named rarities are currently used by +// craft-only models and must still be preserved in the published attribute directory. +type StarGiftAttributeRarityKind string + +const ( + StarGiftRarityPermille StarGiftAttributeRarityKind = "permille" + StarGiftRarityUncommon StarGiftAttributeRarityKind = "uncommon" + StarGiftRarityRare StarGiftAttributeRarityKind = "rare" + StarGiftRarityEpic StarGiftAttributeRarityKind = "epic" + StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary" +) + +func (k StarGiftAttributeRarityKind) Valid() bool { + switch k { + case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare, + StarGiftRarityEpic, StarGiftRarityLegendary: + return true + default: + return false + } +} + +// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille +// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。 type StarGiftCollectibleAttribute struct { ID int64 CollectibleRevisionID int64 @@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct { EdgeColor int PatternColor int TextColor int + RarityKind StarGiftAttributeRarityKind RarityPermille int + Crafted bool + OfficialDocumentID int64 SortOrder int Animation *StarGiftAnimation Blob *FileBlob @@ -79,33 +161,37 @@ type StarGiftCollectibleAttribute struct { // StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。 type StarGiftCollectibleRevision struct { - ID int64 - GiftID int64 - Revision int - UpgradeStars int64 - SupplyTotal int - Issued int - SlugPrefix string - Published bool - Models []StarGiftCollectibleAttribute - Patterns []StarGiftCollectibleAttribute - Backdrops []StarGiftCollectibleAttribute - CreatedBy string - CreatedAt time.Time - PublishedAt time.Time + ID int64 + GiftID int64 + Revision int + UpgradeStars int64 + SupplyTotal int + Issued int + SlugPrefix string + Published bool + Models []StarGiftCollectibleAttribute + Patterns []StarGiftCollectibleAttribute + Backdrops []StarGiftCollectibleAttribute + CreatedBy string + CreatedAt time.Time + PublishedAt time.Time + OfficialGiftID int64 + SourceManifestSHA256 []byte } // StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。 type StarGiftCollectibleWrite struct { - GiftID int64 - UpgradeStars int64 - SupplyTotal int - SlugPrefix string - Models []StarGiftCollectibleAttribute - Patterns []StarGiftCollectibleAttribute - Backdrops []StarGiftCollectibleAttribute - Actor string - CommandID string + GiftID int64 + UpgradeStars int64 + SupplyTotal int + SlugPrefix string + Models []StarGiftCollectibleAttribute + Patterns []StarGiftCollectibleAttribute + Backdrops []StarGiftCollectibleAttribute + Actor string + CommandID string + OfficialGiftID int64 + SourceManifestSHA256 []byte } // UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。 @@ -118,6 +204,26 @@ type UniqueStarGift struct { Slug string Num int Owner Peer + RequirePremium bool + ResaleTonOnly bool + ThemeAvailable bool + Burned bool + Crafted bool + OwnerName string + OwnerAddress string + GiftAddress string + ResellAmount *StarGiftAmount + ResellVersion int64 + ReleasedBy Peer + ValueAmount int64 + ValueCurrency string + ValueUSD int64 + ThemePeer Peer + Host Peer + OfferMinStars int + CraftChancePermille int + LastSaleDate int + LastSaleAmount *StarGiftAmount Model StarGiftCollectibleAttribute Pattern StarGiftCollectibleAttribute Backdrop StarGiftCollectibleAttribute @@ -132,6 +238,56 @@ type UniqueStarGift struct { CreatedAt time.Time } +// CollectibleEmojiStatus projects an immutable unique gift into the complete +// status shape consumed by Telegram clients. Ownership/lifecycle validation +// is intentionally performed by the caller because it depends on the actor; +// this helper validates only the immutable renderable facts. +func CollectibleEmojiStatus(g UniqueStarGift) (EmojiStatusCollectible, bool) { + status := EmojiStatusCollectible{ + CollectibleID: g.ID, + Title: g.Title, + Slug: g.Slug, + CenterColor: g.Backdrop.CenterColor, + EdgeColor: g.Backdrop.EdgeColor, + PatternColor: g.Backdrop.PatternColor, + TextColor: g.Backdrop.TextColor, + } + if g.Model.Document != nil { + status.DocumentID = g.Model.Document.ID + } + if g.Pattern.Document != nil { + status.PatternDocumentID = g.Pattern.Document.ID + } + return status, status.Valid() +} + +type StarGiftCurrency string + +const ( + StarGiftCurrencyStars StarGiftCurrency = "XTR" + StarGiftCurrencyTON StarGiftCurrency = "TON" +) + +type StarGiftAmount struct { + Currency StarGiftCurrency + Amount int64 + Nanos int +} + +func (a StarGiftAmount) Valid() bool { + if a.Amount <= 0 { + return false + } + switch a.Currency { + case StarGiftCurrencyStars: + return a.Nanos >= -999999999 && a.Nanos <= 999999999 + case StarGiftCurrencyTON: + return a.Nanos == 0 + default: + return false + } +} + // StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。 type StarGiftUpgradePreview struct { GiftID int64 @@ -171,7 +327,205 @@ type StarGiftUpgradeRequest struct { OriginSessionID int64 } +type StarGiftPurchaseRequest struct { + BuyerUserID int64 + BuyerPremium bool + To Peer + GiftID int64 + RevisionID int64 + IncludeUpgrade bool + HideName bool + Message string + ChargeStars int64 + FormID int64 + CommandKey string + Date int + RecipientBlocked bool + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// StarGiftPurchaseForm is the server-issued, short-lived payment intent that +// binds payments.getPaymentForm to one later payments.sendStarsForm call. A +// fresh form represents a fresh purchase even when every invoice field is the +// same; retrying one form represents the same purchase command. +type StarGiftPurchaseForm struct { + FormID int64 + BuyerUserID int64 + To Peer + GiftID int64 + RevisionID int64 + IncludeUpgrade bool + HideName bool + Message string + ChargeStars int64 + IssuedAt int + ExpiresAt int +} + +type StarGiftPurchaseResult struct { + Gift StarGift + Saved SavedStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +// StarGiftConvertRequest identifies one owner-scoped regular gift conversion. +// ActorUserID is the authenticated user who owns the user gift or administers +// the channel gift; authorization is checked again at the RPC boundary. +type StarGiftConvertRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + Date int +} + +// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is +// the post-credit balance of either the user or the channel internal Stars +// ledger selected by Saved.Owner. +type StarGiftConvertResult struct { + Saved SavedStarGift + OwnerBalance int64 +} + type StarGiftUpgradeResult struct { + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Send SendPrivateTextResult + SourceEdits []EditedMessageForUser + Duplicate bool +} + +// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a +// committed upgrade after the saved gift has entered its unique terminal state. +// In particular, a paid replay must not be rebound to a later catalog price. +type StarGiftUpgradeReceipt struct { + UserID int64 + SourceSavedGiftID int64 + FormID int64 + UniqueGiftID int64 + ChargeStars int64 + BalanceAfter int64 + SourceEditPts int + RequirePrepaid bool + KeepOriginalDetails bool +} + +type StarGiftPrepaidUpgradeRequest struct { + PayerUserID int64 + Owner Peer + Hash string + ChargeStars int64 + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftPrepaidUpgradeResult struct { + Saved SavedStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +type StarGiftDropOriginalDetailsRequest struct { + UserID int64 + Ref SavedStarGiftRef + ChargeStars int64 + FormID int64 + CommandKey string + Date int +} + +type StarGiftDropOriginalDetailsResult struct { + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Duplicate bool +} + +// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular +// gift becomes collectible. It deliberately contains no wallet/node/provider +// configuration: TON remains only a currency unit in the local ledger. +type StarGiftLifecyclePolicy struct { + TransferStars int64 + DropOriginalDetailsStars int64 + OfferMinStars int + ExportDelaySeconds int + TransferDelaySeconds int + ResellDelaySeconds int + CraftDelaySeconds int + CraftChancePermille int +} + +func (p StarGiftLifecyclePolicy) Valid() bool { + return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 && + p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 && + p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000 +} + +// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator. +// Proceeds permille is the seller share; the remainder is recorded as platform +// commission. TON is still only a unit in the local ledger. +type StarGiftMarketPolicy struct { + StarsProceedsPermille int + TONProceedsPermille int +} + +func (p StarGiftMarketPolicy) Valid() bool { + return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 && + p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000 +} + +type StarGiftResaleFilter struct { + GiftID int64 + SortByPrice bool + SortByNum bool + ForCraft bool + StarsOnly bool + ModelIDs []int64 + PatternIDs []int64 + BackdropIDs []int64 + Offset string + Limit int +} + +type StarGiftResalePage struct { + Gifts []UniqueStarGift + Count int + NextOffset string +} + +type StarGiftValueInfo struct { + Currency string + Value int64 + ValueIsAverage bool + InitialSaleDate int + InitialSaleStars int64 + InitialSalePrice int64 + LastSaleDate int + LastSalePrice int64 + FloorPrice int64 + AveragePrice int64 + ListedCount int +} + +type StarGiftTransferRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + To Peer + ChargeStars int64 + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftTransferResult struct { Saved SavedStarGift Unique UniqueStarGift Balance StarsBalance @@ -179,6 +533,161 @@ type StarGiftUpgradeResult struct { Duplicate bool } +type StarGiftListingRequest struct { + ActorUserID int64 + Ref SavedStarGiftRef + Amount *StarGiftAmount + Date int +} + +type StarGiftResalePurchaseRequest struct { + BuyerUserID int64 + Slug string + To Peer + Amount StarGiftAmount + FormID int64 + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftOfferRequest struct { + BuyerUserID int64 + Owner Peer + Slug string + Price StarGiftAmount + Duration int + RandomID int64 + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftOffer struct { + ID int64 + BuyerUserID int64 + Owner Peer + UniqueGiftID int64 + Price StarGiftAmount + RandomID int64 + OfferMsgID int + BuyerMsgID int + Status string + CreatedAt int + ExpiresAt int + ResolvedAt int + Gift UniqueStarGift +} + +type StarGiftOfferResult struct { + Offer StarGiftOffer + Saved SavedStarGift + Unique UniqueStarGift + Balance StarsBalance + Send SendPrivateTextResult + Duplicate bool +} + +type StarGiftResolveOfferRequest struct { + OwnerUserID int64 + OfferMsgID int + Decline bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftCraftRequest struct { + UserID int64 + Refs []SavedStarGiftRef + CommandKey string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +type StarGiftCraftResult struct { + Success bool + Chance int + Gift *UniqueStarGift + Send SendPrivateTextResult + SourceEdits []EditedMessageForUser + Duplicate bool +} + +type StarGiftAuction struct { + Gift StarGift + Version int + StartDate int + EndDate int + MinBidAmount int64 + NextRoundAt int + LastGiftNum int + GiftsLeft int + CurrentRound int + TotalRounds int + RoundDuration int + BidLevels []StarGiftAuctionBidLevel + TopBidders []int64 + UserState StarGiftAuctionUserState + Finished bool + AveragePrice int64 + ListedCount int +} + +type StarGiftAuctionBidLevel struct { + Pos int + Amount int64 + Date int +} + +type StarGiftAuctionUserState struct { + Returned bool + BidAmount int64 + BidDate int + MinBidAmount int64 + BidPeer Peer + AcquiredCount int +} + +type StarGiftAuctionBidRequest struct { + UserID int64 + GiftID int64 + Peer Peer + BidAmount int64 + HideName bool + Message string + UpdateBid bool + FormID int64 + Date int +} + +type StarGiftAuctionAcquired struct { + Peer Peer + Date int + BidAmount int64 + Round int + Pos int + Message string + GiftNum int + NameHidden bool +} + +type StarGiftWithdrawalRequest struct { + UserID int64 + Ref SavedStarGiftRef + Date int +} + +type StarGiftWithdrawal struct { + ProviderRequestID string + URL string + ExpiresAt int + Status string + Gift UniqueStarGift +} + // StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。 type StarGiftCollection struct { Owner Peer @@ -223,17 +732,53 @@ type StarGiftAnimation struct { // StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。 type StarGiftCatalogWrite struct { - GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision - Title string - Stars int64 - ConvertStars int64 - Enabled bool - SortOrder int - Document Document - Blob FileBlob - Animation StarGiftAnimation - Actor string - CommandID string + GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision + Title string + Stars int64 + ConvertStars int64 + Enabled bool + SortOrder int + Document Document + Blob FileBlob + Animation StarGiftAnimation + Actor string + CommandID string + OfficialGiftID int64 + SourceManifestSHA256 []byte + OfficialSourceJSON []byte + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityRemains int + AvailabilityTotal int + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + ReleasedBy Peer + PerUserTotal int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *StarGiftBackground +} + +// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional +// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID. +type StarGiftCatalogBundleWrite struct { + Catalog StarGiftCatalogWrite + Collectible *StarGiftCollectibleWrite +} + +type StarGiftCatalogBundleResult struct { + Catalog StarGiftCatalogEntry + Collectible *StarGiftCollectibleRevision } // StarGiftCatalogEntry 是管理后台目录视图。 @@ -255,20 +800,24 @@ type StarGiftCatalogEntry struct { } // SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。 -// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。 +// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id; +// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。 type SavedStarGiftRef struct { Owner Peer MsgID int SavedID int64 + Slug string } // Valid reports whether the reference has the identity required by its owner kind. func (r SavedStarGiftRef) Valid() bool { + slug := strings.TrimSpace(r.Slug) + validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0 switch r.Owner.Type { case PeerTypeUser: - return r.Owner.ID != 0 && r.MsgID > 0 + return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "") case PeerTypeChannel: - return r.Owner.ID != 0 && r.SavedID > 0 + return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "") default: return false } @@ -281,6 +830,14 @@ type SavedStarGiftPage struct { Count int // 总数(未转换、按 excludeUnsaved 过滤后) } +// SavedStarGiftListCursor is the composite keyset cursor for the profile gift +// order: pinned gifts first by PinnedOrder, then unpinned gifts by ID DESC. +// PinnedOrder == 0 identifies the unpinned segment. +type SavedStarGiftListCursor struct { + PinnedOrder int + ID int64 +} + // SavedStarGiftFilter describes the client-visible filters supported by // payments.getSavedStarGifts. CollectionID is the collection membership filter; // zero means all collections. The current catalog is used only to decide whether @@ -317,7 +874,8 @@ const ( // MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。 MaxStarGiftCatalogSize = 500 MaxStarGiftTitleRunes = 128 - MaxStarGiftCollectibleAttributesPerKind = 256 + MaxStarGiftSlugBytes = 255 + MaxStarGiftCollectibleAttributesPerKind = 512 MaxStarGiftCollectionTitleRunes = 12 MaxStarGiftCollectionsPerPeer = 100 MaxStarGiftCollectionItems = 1000 @@ -339,6 +897,18 @@ var ( ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition") ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found") ErrStarGiftCollectionsFull = errors.New("stargift: collections full") + ErrStarGiftUnavailable = errors.New("stargift: unavailable") + ErrStarGiftOwnerInvalid = errors.New("stargift: owner invalid") + ErrStarGiftTransferUnavailable = errors.New("stargift: transfer unavailable") + ErrStarGiftResaleUnavailable = errors.New("stargift: resale unavailable") + ErrStarGiftOfferInvalid = errors.New("stargift: offer invalid") + ErrStarGiftOfferExpired = errors.New("stargift: offer expired") + ErrStarGiftCraftUnavailable = errors.New("stargift: craft unavailable") + ErrStarGiftAuctionUnavailable = errors.New("stargift: auction unavailable") + ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable") + ErrStarGiftFormExpired = errors.New("stargift: payment form expired") + ErrStarGiftFormPurposeInvalid = errors.New("stargift: payment form purpose invalid") + ErrStarGiftFormAmountMismatch = errors.New("stargift: payment form amount mismatch") ) var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`) @@ -351,6 +921,11 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error { !starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" { return ErrStarGiftCollectibleInvalid } + if write.OfficialGiftID < 0 || + (write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) || + (write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) { + return ErrStarGiftCollectibleInvalid + } if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil { return err } @@ -380,11 +955,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } seen := make(map[string]struct{}, len(attributes)) - total := 0 + selectable := 0 for _, attribute := range attributes { name := strings.TrimSpace(attribute.Name) - if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || - attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 { + rarityKind := attribute.RarityKind + if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() { + return ErrStarGiftCollectibleInvalid + } + if rarityKind == StarGiftRarityPermille { + if attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 || attribute.Crafted { + return ErrStarGiftCollectibleInvalid + } + selectable++ + } else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel { return ErrStarGiftCollectibleInvalid } key := strings.ToLower(name) @@ -392,19 +975,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } seen[key] = struct{}{} - total += attribute.RarityPermille switch kind { case StarGiftCollectibleModel, StarGiftCollectiblePattern: if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 || len(attribute.Animation.TGS) == 0 || len(attribute.Animation.SHA256) != 32 { return ErrStarGiftCollectibleInvalid } - if requireStoredAsset && (attribute.Document == nil || !attribute.Document.IsSticker() || + if requireStoredAsset && (attribute.Document == nil || + !validStarGiftCollectibleDocument(*attribute.Document, kind) || attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) { return ErrStarGiftCollectibleInvalid } case StarGiftCollectibleBackdrop: - if attribute.BackdropID <= 0 || attribute.Document != nil || + if attribute.BackdropID < 0 || attribute.Document != nil || attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff || attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff || attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff || @@ -415,12 +998,44 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind return ErrStarGiftCollectibleInvalid } } - if total != 1000 { + if selectable == 0 { return ErrStarGiftCollectibleInvalid } return nil } +// validStarGiftCollectibleDocument enforces the client-visible document roles +// materialized by the Star Gift write boundary. Models are ordinary stickers. +// Patterns are text-color custom emoji with an inline PhotoPathSize so Android +// can classify and tint the TGS before its full first frame is downloaded. +func validStarGiftCollectibleDocument(document Document, kind StarGiftCollectibleAttributeKind) bool { + renderAttributes := 0 + validRenderAttribute := false + for _, attribute := range document.Attributes { + switch attribute.Kind { + case DocAttrSticker: + renderAttributes++ + validRenderAttribute = validRenderAttribute || kind == StarGiftCollectibleModel + case DocAttrCustomEmoji: + renderAttributes++ + validRenderAttribute = validRenderAttribute || + (kind == StarGiftCollectiblePattern && attribute.TextColor) + } + } + if renderAttributes != 1 || !validRenderAttribute { + return false + } + if kind == StarGiftCollectibleModel { + return true + } + for _, thumb := range document.Thumbs { + if thumb.Kind == PhotoSizeKindPath && strings.TrimSpace(thumb.Type) != "" && len(thumb.Bytes) > 0 { + return true + } + } + return false +} + // StarGiftCatalogHash 由客户端可见目录字段折叠出稳定 hash,供 getStarGifts NotModified。 func StarGiftCatalogHash(catalog []StarGift) int { var h uint64 @@ -462,7 +1077,44 @@ func StarGiftCollectionHash(title string, giftIDs []int64) int64 { return int64(h & 0x7fffffffffffffff) } -// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id)。 +// EncodeSavedStarGiftListCursor encodes the exact profile-order key of the last +// visible gift. The version prefix keeps this cursor distinct from other star +// gift lists that are ordered only by instance ID. +func EncodeSavedStarGiftListCursor(pinnedOrder int, id int64) string { + if pinnedOrder < 0 || id <= 0 { + return "" + } + raw := "v1:" + strconv.Itoa(pinnedOrder) + ":" + strconv.FormatInt(id, 10) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +// DecodeSavedStarGiftListCursor decodes a profile gift list cursor. Invalid or +// obsolete cursor shapes are rejected instead of being normalized on read. +func DecodeSavedStarGiftListCursor(s string) (SavedStarGiftListCursor, bool) { + if s == "" { + return SavedStarGiftListCursor{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return SavedStarGiftListCursor{}, false + } + parts := strings.Split(string(raw), ":") + if len(parts) != 3 || parts[0] != "v1" { + return SavedStarGiftListCursor{}, false + } + order, err := strconv.ParseInt(parts[1], 10, 32) + if err != nil || order < 0 { + return SavedStarGiftListCursor{}, false + } + id, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil || id <= 0 { + return SavedStarGiftListCursor{}, false + } + return SavedStarGiftListCursor{PinnedOrder: int(order), ID: id}, true +} + +// EncodeStarGiftCursor / DecodeStarGiftCursor are simple instance-ID cursors +// used by star gift lists whose order is strictly ID DESC (for example craft). func EncodeStarGiftCursor(id int64) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) } diff --git a/internal/domain/star_gift_collectible_test.go b/internal/domain/star_gift_collectible_test.go new file mode 100644 index 00000000..e8c37eb8 --- /dev/null +++ b/internal/domain/star_gift_collectible_test.go @@ -0,0 +1,147 @@ +package domain + +import ( + "crypto/sha256" + "errors" + "strings" + "testing" +) + +func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) { + user := Peer{Type: PeerTypeUser, ID: 42} + channel := Peer{Type: PeerTypeChannel, ID: 84} + tests := []struct { + name string + ref SavedStarGiftRef + want bool + }{ + {name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true}, + {name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true}, + {name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true}, + {name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true}, + {name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}}, + {name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}}, + {name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}}, + {name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.Valid(); got != tt.want { + t.Fatalf("Valid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) { + if StarGiftLifecycleStatus("").Live() { + t.Fatal("empty lifecycle status must not be treated as active") + } + if !StarGiftLifecycleActive.Live() { + t.Fatal("active lifecycle status must be live") + } +} + +func validCollectibleDraft() StarGiftCollectibleWrite { + animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)} + return StarGiftCollectibleWrite{ + GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test", + Models: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation}, + {Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation}, + }, + Patterns: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation}, + }, + Backdrops: []StarGiftCollectibleAttribute{ + {Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999}, + }, + } +} + +func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) { + write := validCollectibleDraft() + write.OfficialGiftID = 10 + write.SourceManifestSHA256 = make([]byte, sha256.Size) + if err := ValidateStarGiftCollectibleDraft(write); err != nil { + t.Fatalf("valid official draft: %v", err) + } + + tests := map[string]StarGiftCollectibleWrite{} + withoutHash := write + withoutHash.SourceManifestSHA256 = nil + tests["official ID without hash"] = withoutHash + withoutID := write + withoutID.OfficialGiftID = 0 + tests["hash without official ID"] = withoutID + negativeID := write + negativeID.OfficialGiftID = -1 + tests["negative official ID"] = negativeID + for name, invalid := range tests { + t.Run(name, func(t *testing.T) { + if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) { + t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err) + } + }) + } +} + +func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) { + write := validCollectibleDraft() + write.Models[0].RarityKind = "" + if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) { + t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err) + } +} + +func storedCollectibleWrite() StarGiftCollectibleWrite { + write := validCollectibleDraft() + for i := range write.Models { + write.Models[i].Document = &Document{ + ID: int64(100 + i), MimeType: "application/x-tgsticker", + Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}, + } + write.Models[i].Blob = &FileBlob{LocationKey: "model"} + } + write.Patterns[0].Document = &Document{ + ID: 200, MimeType: "application/x-tgsticker", + Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}, + Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}}, + } + write.Patterns[0].Blob = &FileBlob{LocationKey: "pattern"} + return write +} + +func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) { + if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil { + t.Fatalf("valid stored collectible: %v", err) + } + + tests := map[string]func(*StarGiftCollectibleWrite){ + "pattern stored as sticker": func(write *StarGiftCollectibleWrite) { + write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}} + }, + "pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) { + write.Patterns[0].Document.Attributes[0].TextColor = false + }, + "pattern without inline path thumb": func(write *StarGiftCollectibleWrite) { + write.Patterns[0].Document.Thumbs = nil + }, + "model stored as custom emoji": func(write *StarGiftCollectibleWrite) { + write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}} + }, + "ambiguous model render attributes": func(write *StarGiftCollectibleWrite) { + write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes, + DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + write := storedCollectibleWrite() + mutate(&write) + if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) { + t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err) + } + }) + } +} diff --git a/internal/domain/star_gift_cursor_test.go b/internal/domain/star_gift_cursor_test.go new file mode 100644 index 00000000..9a0bcc21 --- /dev/null +++ b/internal/domain/star_gift_cursor_test.go @@ -0,0 +1,31 @@ +package domain + +import "testing" + +func TestSavedStarGiftListCursorRoundTrip(t *testing.T) { + want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000} + encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID) + got, ok := DecodeSavedStarGiftListCursor(encoded) + if !ok || got != want { + t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want) + } + + unpinned := SavedStarGiftListCursor{ID: 42} + got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID)) + if !ok || got != unpinned { + t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned) + } +} + +func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) { + for _, cursor := range []string{ + "not-base64!", + EncodeStarGiftCursor(42), + EncodeSavedStarGiftListCursor(-1, 42), + EncodeSavedStarGiftListCursor(1, 0), + } { + if got, ok := DecodeSavedStarGiftListCursor(cursor); ok { + t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got) + } + } +} diff --git a/internal/domain/stars.go b/internal/domain/stars.go index 383a188d..dc3f1b60 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -3,6 +3,7 @@ package domain import ( "encoding/base64" "errors" + "fmt" "strconv" ) @@ -20,13 +21,20 @@ type StarsBalance struct { type StarsTransactionReason string const ( - StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 - StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) - StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 - StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 - StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物 - StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 - StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 + StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 + StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) + StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 + StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 + StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物 + StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer" + StarsReasonGiftResale StarsTransactionReason = "gift_resale" + StarsReasonGiftOffer StarsTransactionReason = "gift_offer" + StarsReasonGiftAuction StarsTransactionReason = "gift_auction" + StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade" + StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details" + StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 + StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费 + StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 ) // StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。 @@ -52,6 +60,28 @@ type StarsTransactionPage struct { Users []User // History 中提到的对手方用户,供 tg Users 富化 } +// TonTransaction is an entry in telesrv's internal nanoton ledger. It models +// the Telegram TON-denominated gift UI without contacting a wallet, Fragment, +// a TON node, or any blockchain service. +type TonTransaction struct { + ID int64 + UserID int64 + Peer Peer + GiftID int64 + Amount int64 // signed nanoton amount + Date int + Reason StarsTransactionReason + Title string + Description string +} + +type TonTransactionPage struct { + Balance int64 + Transactions []TonTransaction + NextOffset string + Users []User +} + // Stars 账本边界常量。 const ( // DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。 @@ -70,6 +100,17 @@ var ( ErrStarsInvalidAmount = errors.New("stars: invalid amount") ) +// StarsPaymentRequiredError reports the minimum paid-message authorization the +// sender must include in allow_paid_stars. The authorization is a ceiling; the +// ledger debits only the channel's current configured price. +type StarsPaymentRequiredError struct { + Stars int64 +} + +func (e *StarsPaymentRequiredError) Error() string { + return fmt.Sprintf("stars: allow payment required: %d", e.Stars) +} + // EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。 func EncodeStarsCursor(id int64) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index 3d37da73..0c1aa7b8 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -12,6 +12,9 @@ const ( UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox" UpdateEventReadMessageContents UpdateEventType = "read_message_contents" UpdateEventEditMessage UpdateEventType = "edit_message" + // UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号 + // pts/difference/outbox。 + UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query" // UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览 // 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建, // 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。 @@ -29,8 +32,11 @@ const ( UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked" // UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL // 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。 - UpdateEventUserPhone UpdateEventType = "user_phone" - UpdateEventDeleteMessages UpdateEventType = "delete_messages" + UpdateEventUserPhone UpdateEventType = "user_phone" + // UpdateEventUserEmojiStatus carries the exact immutable status snapshot. + // It consumes account pts even though updateUserEmojiStatus has no pts. + UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status" + UpdateEventDeleteMessages UpdateEventType = "delete_messages" // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消 // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 @@ -81,6 +87,7 @@ type UpdateEvent struct { Peers []Peer Bool bool Phone string + EmojiStatus UserEmojiStatus Settings PeerSettings MessageIDs []int MaxID int @@ -106,6 +113,11 @@ type UpdateEvent struct { QuickReplies []QuickReply QuickReply QuickReply QuickReplyMessage QuickReplyMessage + BotCallbackQuery *BotCallbackQuery + // BotAPIUpdateID is the HTTP Bot API update_id. It is intentionally separate + // from MTProto Pts: Bot API ephemeral envelopes never advance account state. + BotAPIUpdateID int64 + EphemeralMessage *EphemeralMessage } // LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有 @@ -127,6 +139,7 @@ func (e UpdateEvent) LacksWirePts() bool { UpdateEventPeerSettings, UpdateEventPeerStoryBlocked, UpdateEventUserPhone, + UpdateEventUserEmojiStatus, UpdateEventDialogFilter, UpdateEventDialogFilterOrder, UpdateEventDialogFilters, diff --git a/internal/domain/user.go b/internal/domain/user.go index fb8d5e02..8e95a1ac 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,5 +1,7 @@ package domain +import "time" + // UserIDSequenceBase 是普通用户 ID 的起始值。 // // 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。 @@ -14,6 +16,73 @@ type PeerColor struct { BackgroundEmojiID int64 } +// EmojiStatusCollectible is the immutable projection needed to render a +// collectible gift as an emoji status. The source of truth remains the owned +// UniqueStarGift; users store an immutable snapshot so every user projection, +// online update and offline difference observes the same shape without an +// RPC-layer lookup. +type EmojiStatusCollectible struct { + CollectibleID int64 `json:"collectible_id"` + DocumentID int64 `json:"document_id"` + Title string `json:"title"` + Slug string `json:"slug"` + PatternDocumentID int64 `json:"pattern_document_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` +} + +// Empty reports whether no collectible status is present. +func (s EmojiStatusCollectible) Empty() bool { + return s == (EmojiStatusCollectible{}) +} + +// Valid enforces the complete collectible status shape. Partial snapshots +// are forbidden because clients would otherwise render a gradient without its +// model/pattern or be unable to resolve the collectible link. +func (s EmojiStatusCollectible) Valid() bool { + if s.CollectibleID <= 0 || s.DocumentID <= 0 || s.PatternDocumentID <= 0 || + s.Title == "" || s.Slug == "" { + return false + } + for _, color := range []int{s.CenterColor, s.EdgeColor, s.PatternColor, s.TextColor} { + if color < 0 || color > 0xffffff { + return false + } + } + return true +} + +// UserEmojiStatus is the protocol-neutral mutation value accepted by the user +// service/store boundary. Exactly one of a normal document or a complete +// collectible snapshot may be active; the zero value clears the status. +type UserEmojiStatus struct { + DocumentID int64 `json:"document_id"` + Until int `json:"until,omitempty"` + Collectible EmojiStatusCollectible `json:"collectible,omitempty"` +} + +func (s UserEmojiStatus) Empty() bool { + return s.DocumentID == 0 && s.Collectible.Empty() +} + +func (s UserEmojiStatus) Valid() bool { + if s.Until < 0 { + return false + } + if s.Empty() { + return s.Until == 0 + } + if s.DocumentID <= 0 { + return false + } + if s.Collectible.Empty() { + return true + } + return s.Collectible.Valid() && s.DocumentID == s.Collectible.DocumentID +} + // Empty reports whether no explicit color/profile color state is set. func (c PeerColor) Empty() bool { return !c.HasColor && c.BackgroundEmojiID == 0 @@ -50,14 +119,19 @@ type User struct { PremiumUntil int // EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status //(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置; - // Until==0 表示永久。 - EmojiStatusDocumentID int64 - EmojiStatusUntil int + // Until==0 表示永久。EmojiStatusCollectible 非零时 DocumentID 必须等于 + // collectible 的 model document id。 + EmojiStatusDocumentID int64 + EmojiStatusUntil int + EmojiStatusCollectible EmojiStatusCollectible // Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。 Birthday Birthday // PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel); // 0 表示未设置。资料投影时按它取频道对象与最新一帖。 PersonalChannelID int64 + // LinkedCommunityID is the single Community containing this bot. Ordinary + // users must keep it zero; the community aggregate enforces that invariant. + LinkedCommunityID int64 Color PeerColor ProfileColor PeerColor // Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。 @@ -68,6 +142,15 @@ type User struct { PhotoHasVideo bool LastSeenAt int Status UserStatus + // Deleted is the durable tombstone state. Deleted users remain addressable by + // ID so historical messages can render "Deleted Account", but all profile + // and reusable identity fields are cleared at the store boundary. + Deleted bool + DeletedAt int64 + DeletionSource AccountDeletionSource + DeletionReason string + CreatedAt time.Time + AccountDeleteAt time.Time } // PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。 @@ -80,12 +163,40 @@ func (u User) PremiumActiveAt(now int64) bool { // (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期 // 降级后即便列仍有残值也不再下发。 func (u User) EmojiStatusActiveAt(now int64) bool { - if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 { + if !u.PremiumActiveAt(now) || !u.EmojiStatus().Valid() || u.EmojiStatusDocumentID == 0 { return false } return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now } +// EmojiStatus returns the complete status snapshot carried by this user. +func (u User) EmojiStatus() UserEmojiStatus { + return UserEmojiStatus{ + DocumentID: u.EmojiStatusDocumentID, + Until: u.EmojiStatusUntil, + Collectible: u.EmojiStatusCollectible, + } +} + +// DeletedTombstone strips every viewer-dependent or personally identifying +// field while preserving the immutable id and lifecycle audit facts. +func (u User) DeletedTombstone() User { + if !u.Deleted { + return u + } + return User{ + ID: u.ID, + AccessHash: u.AccessHash, + Deleted: true, + DeletedAt: u.DeletedAt, + DeletionSource: u.DeletionSource, + DeletionReason: u.DeletionReason, + CreatedAt: u.CreatedAt, + AccountDeleteAt: u.AccountDeleteAt, + Status: UserStatus{Kind: UserStatusEmpty}, + } +} + // UserStatusKind is a protocol-neutral account presence state. type UserStatusKind int diff --git a/internal/mtprotoedge/session_manager.go b/internal/mtprotoedge/session_manager.go index 735a4244..7ff5a463 100644 --- a/internal/mtprotoedge/session_manager.go +++ b/internal/mtprotoedge/session_manager.go @@ -1366,15 +1366,19 @@ func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, use func (m *SessionManager) PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass) (int, error) { // Secret-chat qts is the durable source of truth, so online delivery is an accelerator just // like account pts fan-out. Do not synchronously wait for every PFS/raw connection's socket. - return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, 2*time.Second) + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, 2*time.Second) } // PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transient(typing)best-effort 版本。 func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { - return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, timeout) + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, timeout) } -func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { +func (m *SessionManager) PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, minLayer, t, msg, timeout) +} + +func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { if ctx != nil && ctx.Err() != nil { return 0, ctx.Err() } @@ -1397,7 +1401,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us defer cancel() } getUpdates := onceLayerUpdatesFanout(sendCtx, msg) - return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, func(c *Conn) error { + return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, minLayer, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1420,7 +1424,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us }) } -func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, send func(*Conn) error) (int, error) { +func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, send func(*Conn) error) (int, error) { m.mu.Lock() candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID) conns := make([]*Conn, 0, len(candidates)) @@ -1432,6 +1436,9 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64 // 未就绪:密聊消息靠 getDifference 补,typing 直接丢——都不进 pending。 continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + continue + } conns = append(conns, c) } m.mu.Unlock() @@ -1476,7 +1483,7 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64 func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) { getUpdates := onceLayerUpdatesFanout(ctx, msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, true, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1499,7 +1506,25 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu // 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。 func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { getUpdates := onceLayerUpdatesFanout(ctx, msg) - return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, getUpdates, false, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, false, func(c *Conn) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + updates, err := getUpdates() + if err != nil { + return err + } + encoded, err := updates.prepareForConn(ctx, c) + if err != nil { + return err + } + return c.SendBestEffortEncoded(ctx, t, encoded, timeout) + }) +} + +func (m *SessionManager) PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + getUpdates := onceLayerUpdatesFanout(ctx, msg) + return m.pushToUserWithSender(ctx, userID, nil, 0, minLayer, t, getUpdates, false, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1520,6 +1545,10 @@ func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Co } func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + return m.pushToUserBestEffortAtLeastLayer(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, msg, timeout) +} + +func (m *SessionManager) pushToUserBestEffortAtLeastLayer(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { if ctx != nil && ctx.Err() != nil { return 0, ctx.Err() } @@ -1545,7 +1574,7 @@ func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, defer cancel() } getUpdates := onceLayerUpdatesFanout(sendCtx, msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, minLayer, t, getUpdates, true, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1592,7 +1621,7 @@ func onceLayerUpdatesFanout(ctx context.Context, msg tg.UpdatesClass) func() (*l } } -func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) { +func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) { // push fan-out 是连接层最热路径之一:debug 日志的字段构造(含 auth_key hex 格式化) // 在关闭 debug 时也会求值,先查级别一次、按需记日志。 debug := m.log.Core().Enabled(zapcore.DebugLevel) @@ -1612,6 +1641,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excluded++ continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + skipped++ + continue + } if !c.receivesUpdates.Load() { if !queueWhenNotReady { // transient(typing/presence):未就绪即丢,不进 pending。这些 update 不写 @@ -1640,6 +1673,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excluded++ continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + skipped++ + continue + } if !c.receivesUpdates.Load() { if !queueWhenNotReady { skipped++ @@ -2478,6 +2515,17 @@ func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID i return c.authKeyID == *excludeAuthKeyID } +func sessionSupportsMinimumLayer(c *Conn, minLayer int) bool { + if minLayer <= 0 { + return true + } + if c == nil { + return false + } + state := c.LayerProfileState() + return state.Origin != LayerProfileUnknown && int(state.Profile) >= minLayer +} + func sessionKeyLog(id [8]byte) string { return fmt.Sprintf("%x", id) } diff --git a/internal/mtprotoedge/transient_push_test.go b/internal/mtprotoedge/transient_push_test.go index 4498fb8f..931f2a7d 100644 --- a/internal/mtprotoedge/transient_push_test.go +++ b/internal/mtprotoedge/transient_push_test.go @@ -3,11 +3,13 @@ package mtprotoedge import ( "context" "testing" + "time" "go.uber.org/zap/zaptest" "github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" ) // TestPushTransientSkipsNotReadySession 锁定不变量:transient 推送(typing/presence)对 @@ -51,3 +53,57 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) { t.Fatalf("durable push queued %d pending, want 1", n) } } + +// Layer-228-only transient constructors must be filtered before encoding. A +// Layer 227 or unknown session is skipped without disconnecting it or queuing +// an unreplayable update, while the ready Layer 228 session receives it. +func TestPushTransientAtLeastLayerSkipsOldAndUnknownProfiles(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + const userID = int64(101) + makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn { + c := &Conn{ + sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)}, + outbound: make(chan outboundOp, 2), outboundControl: make(chan outboundOp, 2), + outboundStop: make(chan struct{}), + } + c.userID.Store(userID) + c.userIDResolved.Store(true) + c.receivesUpdates.Store(true) + if known { + if err := c.FreezeLayerProfile(profile); err != nil { + t.Fatal(err) + } + } + if err := sm.Register(c); err != nil { + t.Fatal(err) + } + return c + } + old := makeConn(1, tlprofile.Profile227, true) + current := makeConn(2, tlprofile.Profile228, true) + unknown := makeConn(3, 0, false) + + message := tg.EphemeralMessage{ + ID: 7, FromID: &tg.PeerUser{UserID: 2001}, PeerID: &tg.PeerChannel{ChannelID: 3001}, + ReceiverID: userID, Date: 1_900_000_000, Message: "private", + } + updates := &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateNewEphemeralMessage{Message: message}}, Date: 1_900_000_000} + sent, err := sm.PushToUserTransientAtLeastLayer(context.Background(), userID, 228, proto.MessageFromServer, updates, time.Second) + if err != nil || sent != 1 { + t.Fatalf("sent=%d err=%v", sent, err) + } + if len(old.outbound) != 0 || len(unknown.outbound) != 0 || len(current.outbound) != 1 { + t.Fatalf("queues old=%d unknown=%d current=%d", len(old.outbound), len(unknown.outbound), len(current.outbound)) + } + if old.isRetired() || unknown.isRetired() { + t.Fatal("unsupported transient update retired an old/unknown session") + } + for _, c := range []*Conn{old, current, unknown} { + sm.mu.RLock() + pending := len(sm.pending[connSessionKey(c)]) + sm.mu.RUnlock() + if pending != 0 { + t.Fatalf("session %d queued %d transient updates", c.sessionID, pending) + } + } +} diff --git a/internal/officialgifts/catalog.go b/internal/officialgifts/catalog.go new file mode 100644 index 00000000..20b5ac74 --- /dev/null +++ b/internal/officialgifts/catalog.go @@ -0,0 +1,533 @@ +// Package officialgifts reads the local, immutable snapshot produced by cmd/giftfetch. +// It never performs network I/O. Selected document bytes are accepted only after their +// manifest size and SHA-256 have been verified beneath the configured snapshot root. +package officialgifts + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "telesrv/internal/branding" +) + +const manifestSchema = 2 + +var ( + ErrUnavailable = errors.New("official gifts snapshot is unavailable") + ErrInvalid = errors.New("official gifts snapshot is invalid") + ErrNotFound = errors.New("official gift not found") +) + +type Catalog struct { + root string + once sync.Once + snap *snapshot + err error +} + +type GiftSummary struct { + ID int64 + Title string + Stars int64 + ConvertStars int64 + UpgradeStars int64 + AvailabilityTotal int + AvailabilityRemains int + AvailabilityResale int64 + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *Background + ModelCount int + PatternCount int + BackdropCount int + CraftedModelCount int + DocumentID int64 + AnimationValidated bool +} + +// CanUpgrade reports whether this snapshot contains the complete immutable +// attribute pool and a positive official upgrade price required to mint a +// collectible from the regular gift. +func (g GiftSummary) CanUpgrade() bool { + return g.UpgradeStars > 0 && g.ModelCount > 0 && g.PatternCount > 0 && g.BackdropCount > 0 +} + +// CanCraft reports whether upgraded collectibles from this gift can reach an +// official craft-only model. Craft is not advertised without a valid upgrade +// path even if a malformed snapshot were to contain a crafted model. +func (g GiftSummary) CanCraft() bool { + return g.CanUpgrade() && g.CraftedModelCount > 0 +} + +type Bundle struct { + ManifestSHA256 []byte + SourceJSON []byte + Gift Gift + BaseDocument Document + Collectible *CollectibleSet +} + +type Gift struct { + ID int64 + Title string + Stars int64 + ConvertStars int64 + UpgradeStars int64 + AvailabilityTotal int + AvailabilityRemains int + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityResale int64 + FirstSaleDate int + LastSaleDate int + ResellMinStars int64 + PerUserTotal int + PerUserRemains int + LockedUntilDate int + AuctionSlug string + GiftsPerRound int + AuctionStartDate int + UpgradeVariants int + Background *Background + DocumentID int64 +} + +type Background struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + TextColor int `json:"text_color"` +} + +type CollectibleSet struct { + Models []Model + Patterns []Pattern + Backdrops []Backdrop +} + +type Rarity struct { + Kind string `json:"kind"` + Permille *int `json:"permille,omitempty"` +} + +type Model struct { + Name string + DocumentID int64 + Crafted bool + Rarity Rarity + Document Document +} + +type Pattern struct { + Name string + DocumentID int64 + Rarity Rarity + Document Document +} + +type Backdrop struct { + Name string + BackdropID int + CenterColor int + EdgeColor int + PatternColor int + TextColor int + Rarity Rarity +} + +type Document struct { + ID int64 + FileName string + Path string + Size int64 + SHA256 string + AnimationValidated bool + ValidationError string + Data []byte +} + +type manifest struct { + Schema int `json:"schema"` + GiftCount int `json:"gift_count"` + Gifts []giftManifest `json:"gifts"` + UpgradeAttributeSets []collectibleManifest `json:"upgrade_attribute_sets"` + Documents []documentManifest `json:"documents"` +} + +type giftManifest struct { + Index int `json:"index"` + Kind string `json:"kind"` + ID int64 `json:"id"` + Title string `json:"title"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars"` + UpgradeStars int64 `json:"upgrade_stars"` + Limited bool `json:"limited"` + SoldOut bool `json:"sold_out"` + Birthday bool `json:"birthday"` + RequirePremium bool `json:"require_premium"` + LimitedPerUser bool `json:"limited_per_user"` + PeerColorAvailable bool `json:"peer_color_available"` + Auction bool `json:"auction"` + AvailabilityRemains int `json:"availability_remains"` + AvailabilityTotal int `json:"availability_total"` + AvailabilityResale int64 `json:"availability_resale"` + FirstSaleDate int `json:"first_sale_date"` + LastSaleDate int `json:"last_sale_date"` + ResellMinStars int64 `json:"resell_min_stars"` + PerUserTotal int `json:"per_user_total"` + PerUserRemains int `json:"per_user_remains"` + LockedUntilDate int `json:"locked_until_date"` + AuctionSlug string `json:"auction_slug"` + GiftsPerRound int `json:"gifts_per_round"` + AuctionStartDate int `json:"auction_start_date"` + UpgradeVariants int `json:"upgrade_variants"` + Background *Background `json:"background"` + DocumentIDs []int64 `json:"document_ids"` + SourceJSON []byte `json:"-"` +} + +func (g *giftManifest) UnmarshalJSON(data []byte) error { + type plain giftManifest + var value plain + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *g = giftManifest(value) + var compact bytes.Buffer + if err := json.Compact(&compact, data); err != nil { + return err + } + g.SourceJSON = append([]byte(nil), compact.Bytes()...) + return nil +} + +type collectibleManifest struct { + GiftID64 int64 `json:"gift_id"` + AttributeCount int `json:"attribute_count"` + Models []modelManifest `json:"models"` + Patterns []patternManifest `json:"patterns"` + Backdrops []backdropManifest `json:"backdrops"` +} + +type modelManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Crafted bool `json:"crafted"` + Rarity Rarity `json:"rarity"` +} + +type patternManifest struct { + Name string `json:"name"` + DocumentID int64 `json:"document_id"` + Rarity Rarity `json:"rarity"` +} + +type backdropManifest struct { + Name string `json:"name"` + BackdropID int `json:"backdrop_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` + Rarity Rarity `json:"rarity"` +} + +type documentManifest struct { + ID64 int64 `json:"id"` + FileName string `json:"file_name"` + File fileArtifact `json:"file"` + AnimationValidated bool `json:"animation_validated"` + ValidationError string `json:"validation_error"` +} + +type fileArtifact struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type snapshot struct { + manifestSHA []byte + gifts map[int64]giftManifest + sets map[int64]collectibleManifest + documents map[int64]documentManifest + ordered []int64 +} + +func New(root string) *Catalog { + return &Catalog{root: strings.TrimSpace(root)} +} + +func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) { + snap, err := c.load() + if err != nil { + return nil, err + } + out := make([]GiftSummary, 0, len(snap.ordered)) + for _, id := range snap.ordered { + if err := ctx.Err(); err != nil { + return nil, err + } + gift := snap.gifts[id] + doc := snap.documents[gift.DocumentIDs[0]] + summary := GiftSummary{ + ID: id, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale, + Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday, + RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction, + FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate, + ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal, + PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate, + AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""), GiftsPerRound: gift.GiftsPerRound, + AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants, + Background: cloneBackground(gift.Background), DocumentID: doc.ID64, + AnimationValidated: doc.AnimationValidated, + } + if set, ok := snap.sets[id]; ok { + summary.ModelCount, summary.PatternCount, summary.BackdropCount = len(set.Models), len(set.Patterns), len(set.Backdrops) + for _, model := range set.Models { + if model.Crafted { + summary.CraftedModelCount++ + } + } + } + out = append(out, summary) + } + return out, nil +} + +func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (Bundle, error) { + snap, err := c.load() + if err != nil { + return Bundle{}, err + } + gift, ok := snap.gifts[giftID] + if !ok { + return Bundle{}, ErrNotFound + } + base, err := c.readDocument(ctx, snap.documents[gift.DocumentIDs[0]]) + if err != nil { + return Bundle{}, fmt.Errorf("base document %d: %w", gift.DocumentIDs[0], err) + } + out := Bundle{ + ManifestSHA256: append([]byte(nil), snap.manifestSHA...), + SourceJSON: append([]byte(nil), gift.SourceJSON...), + Gift: Gift{ID: gift.ID, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars, + UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, + AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut, + Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser, + PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction, + AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate, + LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars, + PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains, + LockedUntilDate: gift.LockedUntilDate, AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""), + GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate, + UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background), + DocumentID: gift.DocumentIDs[0]}, + BaseDocument: base, + } + if !includeCollectible { + return out, nil + } + set, ok := snap.sets[giftID] + if !ok { + return Bundle{}, fmt.Errorf("%w: gift %d has no collectible attribute set", ErrInvalid, giftID) + } + collectible := &CollectibleSet{ + Models: make([]Model, 0, len(set.Models)), Patterns: make([]Pattern, 0, len(set.Patterns)), + Backdrops: make([]Backdrop, 0, len(set.Backdrops)), + } + for _, value := range set.Models { + doc, err := c.readDocument(ctx, snap.documents[value.DocumentID]) + if err != nil { + return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err) + } + collectible.Models = append(collectible.Models, Model{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc}) + } + for _, value := range set.Patterns { + doc, err := c.readDocument(ctx, snap.documents[value.DocumentID]) + if err != nil { + return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err) + } + collectible.Patterns = append(collectible.Patterns, Pattern{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc}) + } + for _, value := range set.Backdrops { + collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: branding.UserVisibleText(value.Name, ""), BackdropID: value.BackdropID, + CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, + TextColor: value.TextColor, Rarity: value.Rarity}) + } + out.Collectible = collectible + return out, nil +} + +func cloneBackground(value *Background) *Background { + if value == nil { + return nil + } + copy := *value + return © +} + +func (c *Catalog) load() (*snapshot, error) { + c.once.Do(func() { + if c.root == "" { + c.err = ErrUnavailable + return + } + manifestPath := filepath.Join(c.root, "manifest.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + c.err = fmt.Errorf("%w: %v", ErrUnavailable, err) + return + } + var value manifest + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&value); err != nil { + c.err = fmt.Errorf("%w: decode manifest: %v", ErrInvalid, err) + return + } + if value.Schema != manifestSchema || value.GiftCount != len(value.Gifts) || len(value.Gifts) == 0 { + c.err = fmt.Errorf("%w: unexpected schema or gift count", ErrInvalid) + return + } + sum := sha256.Sum256(raw) + snap := &snapshot{manifestSHA: append([]byte(nil), sum[:]...), gifts: make(map[int64]giftManifest, len(value.Gifts)), + sets: make(map[int64]collectibleManifest, len(value.UpgradeAttributeSets)), documents: make(map[int64]documentManifest, len(value.Documents))} + for _, doc := range value.Documents { + if doc.ID64 <= 0 || doc.File.Size <= 0 || len(doc.File.SHA256) != 64 || strings.TrimSpace(doc.File.Path) == "" { + c.err = fmt.Errorf("%w: invalid document %d", ErrInvalid, doc.ID64) + return + } + if _, duplicate := snap.documents[doc.ID64]; duplicate { + c.err = fmt.Errorf("%w: duplicate document %d", ErrInvalid, doc.ID64) + return + } + snap.documents[doc.ID64] = doc + } + for _, gift := range value.Gifts { + if gift.Kind != "regular" || gift.ID <= 0 || gift.Stars <= 0 || len(gift.DocumentIDs) != 1 { + c.err = fmt.Errorf("%w: invalid gift %d", ErrInvalid, gift.ID) + return + } + if _, ok := snap.documents[gift.DocumentIDs[0]]; !ok { + c.err = fmt.Errorf("%w: gift %d document missing", ErrInvalid, gift.ID) + return + } + if _, duplicate := snap.gifts[gift.ID]; duplicate { + c.err = fmt.Errorf("%w: duplicate gift %d", ErrInvalid, gift.ID) + return + } + snap.gifts[gift.ID] = gift + snap.ordered = append(snap.ordered, gift.ID) + } + for _, set := range value.UpgradeAttributeSets { + if _, ok := snap.gifts[set.GiftID64]; !ok || set.AttributeCount != len(set.Models)+len(set.Patterns)+len(set.Backdrops) || + len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 { + c.err = fmt.Errorf("%w: invalid collectible set %d", ErrInvalid, set.GiftID64) + return + } + if _, duplicate := snap.sets[set.GiftID64]; duplicate { + c.err = fmt.Errorf("%w: duplicate collectible set %d", ErrInvalid, set.GiftID64) + return + } + for _, model := range set.Models { + if _, ok := snap.documents[model.DocumentID]; !ok || !validRarity(model.Rarity, model.Crafted) { + c.err = fmt.Errorf("%w: invalid model %q", ErrInvalid, model.Name) + return + } + } + for _, pattern := range set.Patterns { + if _, ok := snap.documents[pattern.DocumentID]; !ok || !validRarity(pattern.Rarity, false) { + c.err = fmt.Errorf("%w: invalid pattern %q", ErrInvalid, pattern.Name) + return + } + } + for _, backdrop := range set.Backdrops { + if backdrop.BackdropID < 0 || !validRarity(backdrop.Rarity, false) { + c.err = fmt.Errorf("%w: invalid backdrop %q", ErrInvalid, backdrop.Name) + return + } + } + snap.sets[set.GiftID64] = set + } + sort.SliceStable(snap.ordered, func(i, j int) bool { return snap.gifts[snap.ordered[i]].Index < snap.gifts[snap.ordered[j]].Index }) + c.snap = snap + }) + return c.snap, c.err +} + +func validRarity(rarity Rarity, crafted bool) bool { + if rarity.Kind == "permille" { + return !crafted && rarity.Permille != nil && *rarity.Permille > 0 && *rarity.Permille <= 1000 + } + return crafted && rarity.Permille == nil && (rarity.Kind == "uncommon" || rarity.Kind == "rare" || rarity.Kind == "epic" || rarity.Kind == "legendary") +} + +func (c *Catalog) readDocument(ctx context.Context, doc documentManifest) (Document, error) { + if err := ctx.Err(); err != nil { + return Document{}, err + } + root, err := filepath.Abs(c.root) + if err != nil { + return Document{}, err + } + clean := filepath.Clean(filepath.FromSlash(doc.File.Path)) + if filepath.IsAbs(clean) { + return Document{}, ErrInvalid + } + full := filepath.Join(root, clean) + rel, err := filepath.Rel(root, full) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Document{}, ErrInvalid + } + data, err := os.ReadFile(full) + if err != nil { + return Document{}, err + } + if int64(len(data)) != doc.File.Size { + return Document{}, fmt.Errorf("%w: size mismatch", ErrInvalid) + } + sum := sha256.Sum256(data) + if !strings.EqualFold(hex.EncodeToString(sum[:]), doc.File.SHA256) { + return Document{}, fmt.Errorf("%w: sha256 mismatch", ErrInvalid) + } + name := strings.TrimSpace(doc.FileName) + if name == "" { + name = filepath.Base(clean) + } + return Document{ID: doc.ID64, FileName: name, Path: doc.File.Path, Size: doc.File.Size, + SHA256: strings.ToLower(doc.File.SHA256), AnimationValidated: doc.AnimationValidated, + ValidationError: doc.ValidationError, Data: data}, nil +} diff --git a/internal/officialgifts/catalog_test.go b/internal/officialgifts/catalog_test.go new file mode 100644 index 00000000..4e8ac0c6 --- /dev/null +++ b/internal/officialgifts/catalog_test.go @@ -0,0 +1,119 @@ +package officialgifts + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCatalogVerifiesSelectedDocument(t *testing.T) { + root := t.TempDir() + data := []byte("official-tgs") + sum := sha256.Sum256(data) + if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), data, 0o644); err != nil { + t.Fatal(err) + } + value := manifest{Schema: manifestSchema, GiftCount: 1, + Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Title: "Telegram Pin", Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}}, + Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}}, + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + catalog := New(root) + items, err := catalog.List(context.Background()) + if err != nil || len(items) != 1 || items[0].ID != 1 || items[0].Title != "Telesrv Pin" { + t.Fatalf("items=%+v err=%v", items, err) + } + bundle, err := catalog.Bundle(context.Background(), 1, false) + if err != nil || string(bundle.BaseDocument.Data) != string(data) || bundle.Gift.Title != "Telesrv Pin" { + t.Fatalf("bundle=%+v err=%v", bundle, err) + } + if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := catalog.Bundle(context.Background(), 1, false); err == nil { + t.Fatal("tampered document was accepted") + } +} + +func TestConfiguredOfficialSnapshotIsComplete(t *testing.T) { + root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR") + if root == "" { + t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set") + } + catalog := New(root) + items, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + var sets, attributes, crafted, upgradable, craftable int + verifiedDocuments := map[int64]struct{}{} + for _, item := range items { + if item.CanUpgrade() { + upgradable++ + } + if item.CanCraft() { + craftable++ + } + include := item.ModelCount+item.PatternCount+item.BackdropCount > 0 + bundle, err := catalog.Bundle(context.Background(), item.ID, include) + if err != nil { + t.Fatalf("gift %d: %v", item.ID, err) + } + verifiedDocuments[bundle.BaseDocument.ID] = struct{}{} + if bundle.Collectible == nil { + continue + } + sets++ + attributes += len(bundle.Collectible.Models) + len(bundle.Collectible.Patterns) + len(bundle.Collectible.Backdrops) + for _, model := range bundle.Collectible.Models { + verifiedDocuments[model.Document.ID] = struct{}{} + if model.Crafted { + crafted++ + } + } + for _, pattern := range bundle.Collectible.Patterns { + verifiedDocuments[pattern.Document.ID] = struct{}{} + } + } + if len(items) != 149 || sets != 116 || attributes != 40332 || crafted != 108 || upgradable != 114 || craftable != 2 || len(verifiedDocuments) != 8333 { + t.Fatalf("gifts=%d sets=%d attributes=%d crafted=%d upgradable=%d craftable=%d documents=%d", + len(items), sets, attributes, crafted, upgradable, craftable, len(verifiedDocuments)) + } +} + +func TestGiftSummaryCapabilitiesRequireCompleteOfficialFacts(t *testing.T) { + complete := GiftSummary{UpgradeStars: 25, ModelCount: 1, PatternCount: 1, BackdropCount: 1} + if !complete.CanUpgrade() || complete.CanCraft() { + t.Fatalf("complete regular pool capabilities = upgrade:%v craft:%v", complete.CanUpgrade(), complete.CanCraft()) + } + craftable := complete + craftable.CraftedModelCount = 1 + if !craftable.CanUpgrade() || !craftable.CanCraft() { + t.Fatalf("crafted pool capabilities = upgrade:%v craft:%v", craftable.CanUpgrade(), craftable.CanCraft()) + } + for name, invalid := range map[string]GiftSummary{ + "zero upgrade price": {ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing model": {UpgradeStars: 25, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing pattern": {UpgradeStars: 25, ModelCount: 1, BackdropCount: 1, CraftedModelCount: 1}, + "missing backdrop": {UpgradeStars: 25, ModelCount: 1, PatternCount: 1, CraftedModelCount: 1}, + } { + t.Run(name, func(t *testing.T) { + if invalid.CanUpgrade() || invalid.CanCraft() { + t.Fatalf("invalid facts advertised capabilities: %+v", invalid) + } + }) + } +} diff --git a/internal/otpdelivery/delivery.go b/internal/otpdelivery/delivery.go index 1fed3a20..9a681c2f 100644 --- a/internal/otpdelivery/delivery.go +++ b/internal/otpdelivery/delivery.go @@ -29,6 +29,7 @@ const ( PurposeLoginEmailSetup Purpose = "login_email_setup" PurposeLoginEmailChange Purpose = "login_email_change" PurposeChangePhone Purpose = "change_phone" + PurposeConfirmPhone Purpose = "confirm_phone" ) type Request struct { @@ -46,7 +47,7 @@ func (r Request) Validate(now time.Time) error { return fmt.Errorf("delivery id is empty or too long") } switch r.Purpose { - case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone: + case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone: default: return fmt.Errorf("unsupported delivery purpose %q", r.Purpose) } diff --git a/internal/rpc/account.go b/internal/rpc/account.go index db2e0b8e..9f729fa9 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -8,6 +8,7 @@ import ( "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tlprofile" + "telesrv/internal/branding" ioscompat "telesrv/internal/compat/ios" "telesrv/internal/compat/tdesktop" "telesrv/internal/domain" @@ -15,6 +16,15 @@ import ( // registerAccount 注册 account.* RPC handler。 func (r *Router) registerAccount(d *tlprofile.Dispatcher) { + registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) { + return r.onAccountDeleteAccount(ctx, req) + }) + registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) { + return r.onAccountSendConfirmPhoneCode(ctx, req) + }) + registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) { + return r.onAccountConfirmPhone(ctx, req) + }) registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) { return true, nil }) @@ -110,11 +120,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) { Hash) }) registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) { - hash := layerRequest. - Hash - _ = hash - - return tdesktop.CollectibleEmojiStatuses(), nil + return r.onAccountGetCollectibleEmojiStatuses(ctx, layerRequest.Hash) }) registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) { hash := layerRequest. @@ -902,7 +908,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT if err != nil { return false, internalErr() } - if ttl.Days <= 0 { + if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays { return false, tgerr400("TTL_DAYS_INVALID") } if svc, ok := r.accountSettingsSvc(); ok { @@ -1602,10 +1608,10 @@ func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg. return true, nil } -// onAccountUpdateEmojiStatus 持久化用户自定义 emoji status(premium 专属)。 -// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖 -// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给 -// 本人全部在线 session(self user 对象同时携带最新 emoji_status 字段)。 +// onAccountUpdateEmojiStatus persists either a normal custom emoji or a +// complete collectible snapshot. Collectibles must still be locally owned by +// the actor; unsupported constructors are rejected instead of being mistaken +// for a clear operation. func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -1615,33 +1621,105 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji if !ok { return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义 } - var documentID int64 - var until int - if s, ok := status.(*tg.EmojiStatus); ok { - documentID = s.DocumentID - if v, ok := s.GetUntil(); ok { - until = v - } + value, err := r.domainUserEmojiStatus(ctx, userID, status) + if err != nil { + return false, err + } + var ( + u domain.User + event domain.UpdateEvent + durableWrite bool + ) + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if durable, ok := r.deps.Users.(UserEmojiStatusDurableService); ok { + u, event, durableWrite, err = durable.UpdateEmojiStatusWithEvent( + ctx, userID, value, int(r.clock.Now().Unix()), rawAuthKeyIDForOrigin(ctx), sessionID, + ) + } else { + u, err = svc.UpdateEmojiStatus(ctx, userID, value) } - u, err := svc.UpdateEmojiStatus(ctx, userID, documentID, until) if err != nil { if errors.Is(err, domain.ErrPremiumRequired) { return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return false, tgerr400("COLLECTIBLE_INVALID") + } return false, internalErr() } r.invalidateRPCProjectionForUser(u.ID) - r.pushUserUpdates(ctx, u.ID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{ - UserID: u.ID, - EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()), - }}, - Users: []tg.UserClass{r.tgSelfUser(u)}, - Date: int(r.clock.Now().Unix()), - }) + update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)} + if durableWrite { + if sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date, + }) + } else if updates, ok := r.deps.Updates.(UserEmojiStatusUpdatesService); ok { + event, _, recordErr := updates.RecordUserEmojiStatus(ctx, authKeyID, userID, value, rawAuthKeyIDForOrigin(ctx), sessionID) + if recordErr != nil { + return false, internalErr() + } + if sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date, + }) + } else { + // Lightweight test deployments without the durable extension retain the + // previous online-only behavior; production wiring implements it. + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: int(r.clock.Now().Unix()), + }) + } return true, nil } +func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input tg.EmojiStatusClass) (domain.UserEmojiStatus, error) { + switch status := input.(type) { + case *tg.EmojiStatusEmpty: + return domain.UserEmojiStatus{}, nil + case *tg.EmojiStatus: + value := domain.UserEmojiStatus{DocumentID: status.DocumentID} + if until, ok := status.GetUntil(); ok { + value.Until = until + } + if !value.Valid() { + return domain.UserEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return value, nil + case *tg.InputEmojiStatusCollectible: + if r.deps.Gifts == nil || status.CollectibleID <= 0 { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID) + if err != nil { + return domain.UserEmojiStatus{}, internalErr() + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + collectible, valid := domain.CollectibleEmojiStatus(gift) + if !valid { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible} + if until, ok := status.GetUntil(); ok { + value.Until = until + } + if !value.Valid() { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + return value, nil + default: + return domain.UserEmojiStatus{}, inputConstructorInvalidErr() + } +} + // onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。 // 普通 peerColor 可清除(color flag absent)、可显式设置 color=0;collectible // 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。 @@ -1737,6 +1815,41 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6 return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil } +// onAccountGetCollectibleEmojiStatuses returns the actor's active locally +// owned unique gifts as complete emojiStatusCollectible values. The bounded +// list order and hash are stable, so Android can safely reuse its cache. +func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Gifts == nil { + return tdesktop.CollectibleEmojiStatuses(), nil + } + gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit) + if err != nil { + return nil, internalErr() + } + ids := make([]int64, 0, len(gifts)) + statuses := make([]tg.EmojiStatusClass, 0, len(gifts)) + for _, gift := range gifts { + collectible, ok := domain.CollectibleEmojiStatus(gift) + if !ok { + continue + } + ids = append(ids, collectible.CollectibleID) + statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{ + DocumentID: collectible.DocumentID, + Collectible: collectible, + })) + } + catalogHash := mediaCatalogHash(ids) + if hash != 0 && hash == catalogHash { + return &tg.AccountEmojiStatusesNotModified{}, nil + } + return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil +} + func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { if u.ID == 0 { return @@ -1777,12 +1890,12 @@ func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int) Current: a.AuthKeyID == currentAuthKeyID, OfficialApp: true, Hash: a.Hash, - DeviceModel: a.DeviceModel, - Platform: a.Platform, - SystemVersion: a.SystemVersion, + DeviceModel: branding.UserVisibleText(a.DeviceModel, ""), + Platform: branding.UserVisibleClientPlatform(a.Platform), + SystemVersion: branding.UserVisibleText(a.SystemVersion, ""), APIID: a.APIID, - AppName: "Telegram Desktop", - AppVersion: a.AppVersion, + AppName: branding.ClientAppName(a.Platform), + AppVersion: branding.UserVisibleText(a.AppVersion, ""), DateCreated: created, DateActive: active, IP: a.IP, diff --git a/internal/rpc/account_collectible_emoji_status_test.go b/internal/rpc/account_collectible_emoji_status_test.go new file mode 100644 index 00000000..b221c2f0 --- /dev/null +++ b/internal/rpc/account_collectible_emoji_status_test.go @@ -0,0 +1,134 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type collectibleEmojiGiftService struct { + GiftsService + gifts map[int64]domain.UniqueStarGift +} + +func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) { + gift, ok := s.gifts[id] + return gift, ok, nil +} + +func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + out := make([]domain.UniqueStarGift, 0, len(s.gifts)) + for _, gift := range s.gifts { + if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" { + out = append(out, gift) + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift { + return domain.UniqueStarGift{ + ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1", + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, + Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}}, + Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}}, + Backdrop: domain.StarGiftCollectibleAttribute{ + CenterColor: 0x102030, EdgeColor: 0x405060, + PatternColor: 0x708090, TextColor: 0xa0b0c0, + }, + } +} + +func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"}) + if err != nil { + t.Fatal(err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"}) + if err != nil { + t.Fatal(err) + } + users := appusers.NewService(userStore) + if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil { + t.Fatalf("grant premium: %v", err) + } + gift := collectibleEmojiTestGift(owner.ID) + gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}} + r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System) + ownerCtx := WithUserID(ctx, owner.ID) + + listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0) + if err != nil { + t.Fatalf("get collectible statuses: %v", err) + } + statuses, ok := listed.(*tg.AccountEmojiStatuses) + if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 { + t.Fatalf("collectible list = %T %#v", listed, listed) + } + collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible) + if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID || + collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor { + t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0]) + } + if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil { + t.Fatalf("get cached collectible statuses: %v", err) + } else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok { + t.Fatalf("cached collectible statuses = %T, want notModified", cached) + } + + input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID} + input.SetUntil(2_000_000_000) + if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok { + t.Fatalf("set collectible status: ok=%v err=%v", ok, err) + } + self, err := users.Self(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID || + self.EmojiStatusUntil != 2_000_000_000 { + t.Fatalf("persisted collectible status = %+v", self.EmojiStatus()) + } + wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible) + if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor { + t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire) + } + + stolen := gift + stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + gifts.gifts[gift.ID] = stolen + if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") { + t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err) + } +} + +func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) { + collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1)) + if !ok { + t.Fatal("test gift should project") + } + value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible} + update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{ + UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value, + }).(*tg.UpdateUserEmojiStatus) + if !ok { + t.Fatal("durable event did not produce updateUserEmojiStatus") + } + if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID { + t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus) + } +} diff --git a/internal/rpc/account_deletion.go b/internal/rpc/account_deletion.go new file mode 100644 index 00000000..5bd90c98 --- /dev/null +++ b/internal/rpc/account_deletion.go @@ -0,0 +1,152 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + + "telesrv/internal/domain" + "telesrv/internal/postresponse" +) + +type accountDeletionService interface { + DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) + SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error) + ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) + ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) + CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) +} + +func (r *Router) accountDeletionSvc() (accountDeletionService, bool) { + svc, ok := r.deps.Account.(accountDeletionService) + return svc, ok +} + +func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) { + userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 || (!authorized && !passwordPending) { + return false, authKeyUnregisteredErr() + } + svc, ok := r.accountDeletionSvc() + if !ok { + return false, internalErr() + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok || authKeyID == ([8]byte{}) { + return false, authKeyUnregisteredErr() + } + var password *domain.PasswordCheck + if check, present := req.GetPassword(); present { + converted := domainPasswordCheck(check) + password = &converted + } + outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC()) + if err != nil { + return false, accountDeletionErr(err) + } + if outcome.Kind == domain.AccountDeleteDelayed { + wait := outcome.WaitSeconds + if wait < 1 { + wait = 1 + } + return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait)) + } + r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations) + r.invalidateRPCProjectionForUser(userID) + dispatchNotifications := func() { + dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + r.runAccountLifecycleOnce(dispatchCtx, 500) + } + if !postresponse.Register(ctx, dispatchNotifications) { + go dispatchNotifications() + } + return true, nil +} + +func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) { + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !authorized || userID == 0 { + return nil, authKeyUnregisteredErr() + } + svc, ok := r.accountDeletionSvc() + if !ok { + return nil, internalErr() + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash) + if err != nil { + return nil, accountDeletionErr(err) + } + return tgSMSSentCode(hash, delivery.Length), nil +} + +func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) { + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !authorized || userID == 0 { + return false, authKeyUnregisteredErr() + } + svc, ok := r.accountDeletionSvc() + if !ok { + return false, internalErr() + } + authKeyID, _ := AuthKeyIDFrom(ctx) + revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC()) + if err != nil { + return false, accountDeletionErr(err) + } + r.finishDeletedAccountAuthorizations(ctx, userID, revoked) + return true, nil +} + +func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) { + current, _ := AuthKeyIDFrom(ctx) + for _, authorization := range revoked { + a := authorization + finish := func() { + r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID) + r.revokeAuthKeySessions(a.AuthKeyID) + } + if a.AuthKeyID == current { + if postresponse.Register(ctx, finish) { + continue + } + } + finish() + } +} + +func accountDeletionErr(err error) error { + switch { + case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged): + return passwordErr(err) + case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending): + return tgerr.New(400, "HASH_INVALID") + case errors.Is(err, domain.ErrPhoneCodeEmpty): + return phoneCodeEmptyErr() + case errors.Is(err, domain.ErrPhoneCodeInvalid): + return phoneCodeInvalidErr() + case errors.Is(err, domain.ErrPhoneCodeExpired): + return phoneCodeExpiredErr() + case errors.Is(err, domain.ErrAccountDeletionForbidden): + return botMethodInvalidErr() + case errors.Is(err, domain.ErrAccountDeleted): + return authKeyUnregisteredErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/account_deletion_rpc_test.go b/internal/rpc/account_deletion_rpc_test.go new file mode 100644 index 00000000..779e69f3 --- /dev/null +++ b/internal/rpc/account_deletion_rpc_test.go @@ -0,0 +1,177 @@ +package rpc + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/proto" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/postresponse" + "telesrv/internal/store/memory" +) + +func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) { + current := [8]byte{1} + other := [8]byte{2} + accountSvc := &rpcDeletionAccountService{ + Service: appaccount.NewService(memory.NewPasswordStore()), + outcome: domain.AccountDeleteOutcome{ + Kind: domain.AccountDeleteImmediate, + Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{ + {AuthKeyID: current, UserID: 42}, + {AuthKeyID: other, UserID: 42}, + }}, + }, + } + sessions := &deletionCaptureSessions{} + r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77)) + ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"}) + if err != nil || !ok { + t.Fatalf("delete account ok=%v err=%v", ok, err) + } + if sessions.wasClosed(current) { + t.Fatal("current auth key closed before rpc_result delivery") + } + if !sessions.wasClosed(other) { + t.Fatal("other auth key was not revoked immediately") + } + postresponse.Run(ctx) + if !sessions.wasClosed(current) { + t.Fatal("current auth key not closed after rpc_result delivery") + } +} + +func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) { + accountSvc := &rpcDeletionAccountService{ + Service: appaccount.NewService(memory.NewPasswordStore()), + outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800}, + } + r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System) + ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1}) + ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"}) + if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") { + t.Fatalf("delayed delete ok=%v err=%v", ok, err) + } +} + +func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) { + if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) { + t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver") + } + if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) { + t.Fatal("confirm-phone methods must remain fully authorized") + } +} + +func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) { + sessions := &offlineDeletionSessions{} + svc := &deletionWorkerService{} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{ + ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1, + }) + if len(svc.completed) != 1 || svc.completed[0] != 9 { + t.Fatalf("completed notifications = %v, want [9]", svc.completed) + } +} + +func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) { + revoked := [8]byte{3} + svc := &rpcDeletionAccountService{ + Service: appaccount.NewService(memory.NewPasswordStore()), + sweepResults: []domain.AccountDeletionResult{{ + Changed: true, + User: domain.User{ID: 42, Deleted: true}, + RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}}, + }}, + sweepErr: errors.New("later candidate failed"), + } + sessions := &deletionCaptureSessions{} + r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + r.runAccountLifecycleOnce(context.Background(), 10) + if !sessions.wasClosed(revoked) { + t.Fatal("committed deletion authorization was not closed after partial sweep failure") + } +} + +type rpcDeletionAccountService struct { + *appaccount.Service + outcome domain.AccountDeleteOutcome + err error + sweepResults []domain.AccountDeletionResult + sweepErr error +} + +func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) { + return s.outcome, s.err +} + +func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) { + return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil +} + +func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) { + return nil, nil +} + +func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) { + return "", domain.AuthCodeDelivery{}, false, nil +} + +func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) { + return false, nil +} + +func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) { + return s.sweepResults, s.sweepErr +} + +type deletionCaptureSessions struct { + captureSessions + closed [][8]byte +} + +type offlineDeletionSessions struct{ captureSessions } + +func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) { + return 0, nil +} + +type deletionWorkerService struct{ completed []int64 } + +func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) { + return nil, nil +} + +func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) { + return nil, nil +} + +func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error { + s.completed = append(s.completed, id) + return nil +} + +func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int { + s.closed = append(s.closed, id) + return 1 +} + +func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool { + for _, closed := range s.closed { + if closed == id { + return true + } + } + return false +} diff --git a/internal/rpc/account_lifecycle_worker.go b/internal/rpc/account_lifecycle_worker.go new file mode 100644 index 00000000..a19de648 --- /dev/null +++ b/internal/rpc/account_lifecycle_worker.go @@ -0,0 +1,99 @@ +package rpc + +import ( + "context" + "time" + + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +type accountLifecycleWorkerService interface { + SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) + ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) + CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error +} + +// RunAccountLifecycle executes all due account deletion sources through one +// tombstone path and drains the durable non-pts updateUser queue. The queue is +// a crash-safe, bounded online nudge: offline users are completed after the +// first attempt because getDialogs/getHistory hydration independently returns +// the authoritative tombstone. This avoids an immortal retry queue for a +// non-pts update that cannot participate in getDifference. +func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) { + if interval <= 0 { + interval = time.Minute + } + if batch <= 0 { + batch = 500 + } + r.runAccountLifecycleOnce(ctx, batch) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.runAccountLifecycleOnce(ctx, batch) + } + } +} + +func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) { + svc, ok := r.deps.Account.(accountLifecycleWorkerService) + if !ok { + return + } + now := r.clock.Now().UTC() + sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch) + cancel() + for _, result := range results { + if !result.Changed { + continue + } + r.invalidateRPCProjectionForUser(result.User.ID) + r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations) + } + if err != nil { + // SweepDueAccountDeletions may return already-committed results before a + // later candidate fails. Always finish those sessions/caches and drain + // their durable notifications; the failed and remaining candidates are + // retried from their authoritative due rows on the next tick. + r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err)) + } + for { + claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second) + notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute) + claimCancel() + if err != nil { + r.log.Warn("claim account deletion notifications failed", zap.Error(err)) + return + } + for _, notification := range notifications { + r.dispatchAccountDeletionNotification(ctx, svc, notification) + } + if len(notifications) < batch { + return + } + } +} + +func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) { + now := r.clock.Now().UTC() + updates := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}}, + Users: []tg.UserClass{tgUser(domain.User{ + ID: notification.DeletedUserID, + Deleted: true, + })}, + Date: int(now.Unix()), + } + r.pushUserUpdates(ctx, notification.TargetUserID, updates) + if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil { + r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err)) + } +} diff --git a/internal/rpc/account_notify.go b/internal/rpc/account_notify.go index 1e207539..8003c5e8 100644 --- a/internal/rpc/account_notify.go +++ b/internal/rpc/account_notify.go @@ -35,6 +35,12 @@ func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass) return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true case *tg.InputNotifyBroadcasts: return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true + case *tg.InputNotifyCommunity: + ref, ok := inputChannelRef(p.Community) + if !ok { + return domain.NotifyScope{}, false + } + return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: ref.ID}}, true case *tg.InputNotifyPeer: peer, ok := r.domainPeerFromInputPeer(userID, p.Peer) if !ok { @@ -145,6 +151,7 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou updates := make([]tg.UpdateClass, 0, len(exceptions)) userIDs := make([]int64, 0) channelIDs := make([]int64, 0) + communityIDs := make([]int64, 0) for _, ex := range exceptions { if filterPeer != nil && ex.Peer != *filterPeer { continue @@ -163,15 +170,23 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou userIDs = append(userIDs, ex.Peer.ID) case domain.PeerTypeChannel: channelIDs = append(channelIDs, ex.Peer.ID) + case domain.PeerTypeCommunity: + communityIDs = append(communityIDs, ex.Peer.ID) } } if len(updates) == 0 { return empty, nil } + chats := r.tgChatsForChannelIDs(ctx, userID, channelIDs) + if r.deps.Communities != nil && len(communityIDs) > 0 { + if views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs); err == nil { + chats = appendUniqueTGChats(chats, tgCommunityChats(views)...) + } + } return &tg.Updates{ Updates: updates, Users: r.tgUsersForIDs(ctx, userID, userIDs), - Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs), + Chats: chats, Date: int(r.clock.Now().Unix()), }, nil } @@ -260,6 +275,9 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass { case domain.NotifyScopeBroadcasts: return &tg.NotifyBroadcasts{} case domain.NotifyScopePeer: + if scope.Peer.Type == domain.PeerTypeCommunity { + return &tg.NotifyCommunity{CommunityID: scope.Peer.ID} + } peer := tgPeer(scope.Peer) if scope.TopicID != 0 { return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID} @@ -274,7 +292,7 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass { // 显示且跨重启恢复。perf:从 per-user notify 缓存读取(命中即 0 PG),而非每次 getDialogs // 都查 notify_settings——绝大多数用户没有任何自定义静音,缓存命中后零数据库开销。 func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList { - if len(list.Dialogs) == 0 { + if len(list.Dialogs) == 0 && len(list.Communities) == 0 { return list } settings := r.userNotifySettings(ctx, viewerUserID) @@ -287,6 +305,13 @@ func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int6 list.Dialogs[i].NotifySettings = &sc } } + for i := range list.Communities { + peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: list.Communities[i].Community.ID} + if s, ok := settings[peer]; ok { + sc := s.Clone() + list.Communities[i].State.NotifySettings = &sc + } + } return list } diff --git a/internal/rpc/aicompose_webpage.go b/internal/rpc/aicompose_webpage.go index 4e74a20b..081fea46 100644 --- a/internal/rpc/aicompose_webpage.go +++ b/internal/rpc/aicompose_webpage.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "telesrv/internal/branding" "telesrv/internal/domain" ) @@ -42,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string Hash: aiComposeToneWebPageHash(tone), Date: int(now.Unix()), Type: aiComposeToneWebPageType, - SiteName: "Telegram", + SiteName: branding.ProductName, Title: tone.Title, Description: tone.Prompt, ComposeToneEmojiID: tone.EmojiID, diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index b13044cf..5529f6ff 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -18,6 +18,7 @@ import ( "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/app/auth" + "telesrv/internal/branding" "telesrv/internal/domain" ) @@ -589,6 +590,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil { return nil, err } + if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 { + if svc, ok := r.deps.Account.(accountDeletionService); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash) + if handled { + if err != nil { + return nil, accountDeletionErr(err) + } + return tgSMSSentCode(hash, delivery.Length), nil + } + } + } var hash string var err error if scoped, ok := r.deps.Auth.(interface { @@ -606,6 +620,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq } func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) { + if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 { + if svc, ok := r.deps.Account.(accountDeletionService); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash) + if handled { + if err != nil { + return false, accountDeletionErr(err) + } + return true, nil + } + } + } var err error if scoped, ok := r.deps.Auth.(interface { CancelCodeForAuthKey(context.Context, [8]byte, string, string) error @@ -1000,13 +1026,13 @@ func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User, if ci, ok := ClientInfoFrom(ctx); ok { parts := []string{} if ci.DeviceModel != "" { - parts = append(parts, ci.DeviceModel) + parts = append(parts, branding.UserVisibleText(ci.DeviceModel, "")) } if ci.SystemVersion != "" { - parts = append(parts, ci.SystemVersion) + parts = append(parts, branding.UserVisibleText(ci.SystemVersion, "")) } if ci.AppVersion != "" { - parts = append(parts, ci.AppVersion) + parts = append(parts, branding.UserVisibleText(ci.AppVersion, "")) } if len(parts) > 0 { client = strings.Join(parts, " / ") diff --git a/internal/rpc/auth_gate.go b/internal/rpc/auth_gate.go index ab1df015..97325c00 100644 --- a/internal/rpc/auth_gate.go +++ b/internal/rpc/auth_gate.go @@ -39,6 +39,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool { tg.AuthReportMissingCodeRequestTypeID, tg.AuthResetLoginEmailRequestTypeID, tg.AccountGetPasswordRequestTypeID, + // deleteAccount may complete the narrow password_pending login path when + // the user forgot 2FA. The handler resolves only that bound identity. + tg.AccountDeleteAccountRequestTypeID, // 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权, // 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。 tg.AccountSendVerifyEmailCodeRequestTypeID, @@ -46,6 +49,7 @@ func rpcAllowedWithoutAuthorization(id uint32) bool { tg.HelpGetConfigRequestTypeID, tg.HelpGetNearestDCRequestTypeID, tg.HelpGetInviteTextRequestTypeID, + tg.HelpSaveAppLogRequestTypeID, tg.HelpGetAppConfigRequestTypeID, tg.HelpGetCountriesListRequestTypeID, tg.HelpGetTimezonesListRequestTypeID, diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index a0d307a1..faf232d2 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -8,7 +8,10 @@ import ( "time" "unicode/utf8" + "github.com/iamxvbaba/td/tg" + "telesrv/internal/domain" + "telesrv/internal/store" ) var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1} @@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) ( return r.enrichUpdateEvents(ctx, botID, diff.Events), nil } +func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return nil + } + return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed) +} + +func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return nil + } + return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID) +} + +func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return 0, nil + } + return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID) +} + +func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) { + leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore) + if !ok || botID <= 0 { + return true, nil + } + return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl) +} + +func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error { + leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore) + if !ok || botID <= 0 { + return nil + } + return leases.ReleaseBotAPIPollLease(ctx, botID, owner) +} + +func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return errors.New("WEBHOOK_UNSUPPORTED") + } + return webhooks.SetBotAPIWebhook(ctx, config, dropPending) +} + +func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return errors.New("WEBHOOK_UNSUPPORTED") + } + return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending) +} + +func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return domain.BotAPIWebhook{}, false, nil + } + return webhooks.BotAPIWebhook(ctx, botID) +} + +func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil, nil + } + return webhooks.ListDueBotAPIWebhooks(ctx, limit) +} + +func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return false, nil + } + return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl) +} + +func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner) +} + +func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message) +} + +func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt) +} + +func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 { + return nil + } + return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID) +} + // BotAPISendMessage sends a text message as a bot through the normal private // or channel message state machine. Positive chat_id is a user private chat; // -1000000000000-channel_id is a supergroup/channel chat. @@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex if !ok { return domain.Message{}, errors.New("CHAT_ID_INVALID") } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return domain.Message{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil { + return domain.Message{}, err + } if text == "" { return domain.Message{}, errors.New("MESSAGE_EMPTY") } @@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, if !ok { return domain.Message{}, errors.New("CHAT_ID_INVALID") } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return domain.Message{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil { + return domain.Message{}, err + } if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength { return domain.Message{}, errors.New("MESSAGE_TOO_LONG") } @@ -164,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, return res.SenderMessage, nil } +func (r *Router) BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) { + if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 { + return domain.EphemeralMessage{}, errors.New("BOT_INVALID") + } + peer, ok := botAPIPeerFromChatID(input.ChatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return domain.EphemeralMessage{}, errors.New("CHAT_ID_INVALID") + } + if err := domain.ValidateReplyMarkup(input.ReplyMarkup); err != nil { + return domain.EphemeralMessage{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, input.ReplyMarkup); err != nil { + return domain.EphemeralMessage{}, err + } + baseContent := domain.EphemeralContent{ + Message: input.Text, Entities: append([]domain.MessageEntity(nil), input.Entities...), ReplyMarkup: input.ReplyMarkup, + } + if !utf8.ValidString(baseContent.Message) || utf8.RuneCountInString(baseContent.Message) > domain.MaxMessageTextLength || len(baseContent.Entities) > domain.MaxMessageEntityCount || + !validEphemeralEntityBounds(baseContent.Message, baseContent.Entities) { + return domain.EphemeralMessage{}, errors.New("ENTITY_BOUNDS_INVALID") + } + message, _, err := r.deps.Ephemeral.SendFromBotLazy(ctx, domain.SendBotEphemeralRequest{ + BotUserID: input.BotUserID, ReceiverUserID: input.ReceiverUserID, Peer: peer, + TopMessageID: input.TopMessageID, ReplyToEphemeralID: input.ReplyToEphemeralID, + ActionMessageID: input.ReplyToEphemeralID, CallbackQueryID: input.CallbackQueryID, + }, func(buildCtx context.Context) (domain.EphemeralContent, error) { + content := baseContent + if input.DirectMedia != nil { + content.Media = input.DirectMedia + if content.Media.Geo != nil && content.Media.Geo.AccessHash == 0 { + content.Media.Geo.AccessHash, _ = randomGeoAccessHash() + } + if content.Media.Venue != nil && content.Media.Venue.Geo.AccessHash == 0 { + content.Media.Venue.Geo.AccessHash, _ = randomGeoAccessHash() + } + } else if input.Kind != "message" { + media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.Kind, input.File, input.SecondaryFile) + if err != nil { + return domain.EphemeralContent{}, err + } + content.Media = media + } + return content, nil + }) + if err != nil { + return domain.EphemeralMessage{}, ephemeralBotAPIError(err) + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + return message, nil +} + +func (r *Router) BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) { + if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 || input.MessageID <= 0 { + return false, errors.New("MESSAGE_ID_INVALID") + } + peer, ok := botAPIPeerFromChatID(input.ChatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return false, errors.New("CHAT_ID_INVALID") + } + fields := input.Fields + if fields.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(fields.ReplyMarkup); err != nil { + return false, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, fields.ReplyMarkup); err != nil { + return false, err + } + } + if fields.SetMessage && (!utf8.ValidString(fields.Message) || !validEphemeralEntityBounds(fields.Message, fields.Entities) || utf8.RuneCountInString(fields.Message) > domain.MaxMessageTextLength) { + return false, errors.New("ENTITY_BOUNDS_INVALID") + } + message, err := r.deps.Ephemeral.EditFieldsFromBotLazy(ctx, input.BotUserID, input.ReceiverUserID, peer, input.MessageID, input.Mode, func(buildCtx context.Context) (domain.EditEphemeralFields, error) { + built := fields + if input.MediaKind != "" { + media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.MediaKind, input.File, input.SecondaryFile) + if err != nil { + return domain.EditEphemeralFields{}, err + } + built.SetMedia = true + built.Media = media + } + return built, nil + }) + if err != nil { + return false, ephemeralBotAPIError(err) + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushEdit, TargetUserID: message.ReceiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + return true, nil +} + +func (r *Router) BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) { + peer, ok := botAPIPeerFromChatID(chatID) + if r == nil || r.deps.Ephemeral == nil || !ok || peer.Type != domain.PeerTypeChannel { + return false, errors.New("CHAT_ID_INVALID") + } + message, deleted, err := r.deps.Ephemeral.Delete(ctx, botUserID, receiverUserID, peer, messageID) + if err != nil { + return false, ephemeralBotAPIError(err) + } + if deleted { + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: receiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + } + return true, nil +} + +func ephemeralBotAPIError(err error) error { + switch { + case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), errors.Is(err, domain.ErrEphemeralDeleted): + return errors.New("EPHEMERAL_MESSAGE_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralReplyExpired): + return errors.New("EPHEMERAL_ACTION_EXPIRED") + case errors.Is(err, domain.ErrEphemeralPeerInvalid): + return errors.New("CHAT_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralReceiverInvalid): + return errors.New("USER_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch): + return errors.New("CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrEphemeralVersionConflict): + return errors.New("MESSAGE_NOT_MODIFIED") + default: + return err + } +} + +func (r *Router) botAPIEphemeralMedia(ctx context.Context, botID int64, kind string, file, secondary domain.BotAPIFileInput) (*domain.MessageMedia, error) { + if kind == "live_photo" { + photo, err := r.botAPIMedia(ctx, botID, "photo", file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes) + if err != nil { + return nil, err + } + video, err := r.botAPIDocumentMedia(ctx, botID, "video", secondary) + if err != nil { + return nil, err + } + photo.LivePhotoVideo = video.Document + return photo, nil + } + if kind == "photo" { + return r.botAPIMedia(ctx, botID, kind, file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes) + } + return r.botAPIDocumentMedia(ctx, botID, kind, file) +} + +func (r *Router) botAPIDocumentMedia(ctx context.Context, botID int64, kind string, file domain.BotAPIFileInput) (*domain.MessageMedia, error) { + if r.deps.Files == nil { + return nil, errors.New("MEDIA_INVALID") + } + attrs, forceFile, ok := botAPIDocumentKindAttributes(kind, file) + if !ok { + return nil, errors.New("MEDIA_INVALID") + } + var document domain.Document + var err error + switch { + case len(file.Bytes) > 0: + document, err = r.deps.Files.CreateDocumentFromBytes(ctx, file.Bytes, domain.DocumentSpec{MimeType: file.MimeType, Attributes: attrs, ForceFile: forceFile}) + case file.RemoteURL != "": + document, err = r.deps.Files.CreateDocumentFromURL(ctx, file.RemoteURL) + document.Attributes = mergeDocumentAttributes(document.Attributes, attrs) + case file.LocationKey != "": + id, valid := botAPIDocumentID(file.LocationKey) + if !valid { + return nil, errors.New("FILE_ID_INVALID") + } + var found bool + document, found, err = r.deps.Files.GetDocument(ctx, id) + if err == nil && !found { + err = errors.New("FILE_ID_INVALID") + } + default: + err = errors.New("FILE_ID_INVALID") + } + if err != nil { + return nil, botAPIMediaErr(err) + } + if !botAPIDocumentMatchesKind(document, kind) { + return nil, errors.New("MEDIA_INVALID") + } + return messageMediaFromDocument(document, false, 0), nil +} + +func botAPIDocumentKindAttributes(kind string, file domain.BotAPIFileInput) ([]domain.DocumentAttribute, bool, bool) { + filename := botAPIDocumentAttributes(file.FileName) + w, h, duration := file.Width, file.Height, file.Duration + if w <= 0 { + w = 1 + } + if h <= 0 { + h = 1 + } + if duration <= 0 { + duration = 1 + } + switch kind { + case "document": + return filename, true, true + case "animation": + return append(filename, + domain.DocumentAttribute{Kind: domain.DocAttrAnimated}, + domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), NoSound: true}), false, true + case "audio": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Title: file.Title, Performer: file.Performer}), false, true + case "sticker": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrSticker, W: w, H: h, Alt: file.Emoji}), false, true + case "video": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), SupportsStreaming: true}), false, true + case "video_note": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), RoundMessage: true, SupportsStreaming: true}), false, true + case "voice": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Voice: true}), false, true + default: + return nil, false, false + } +} + +func mergeDocumentAttributes(base, additional []domain.DocumentAttribute) []domain.DocumentAttribute { + out := append([]domain.DocumentAttribute(nil), base...) + seen := make(map[domain.DocumentAttributeKind]struct{}, len(base)+len(additional)) + for _, attribute := range base { + seen[attribute.Kind] = struct{}{} + } + for _, attribute := range additional { + if _, exists := seen[attribute.Kind]; exists { + continue + } + seen[attribute.Kind] = struct{}{} + out = append(out, attribute) + } + return out +} + +func botAPIDocumentMatchesKind(document domain.Document, kind string) bool { + has := func(target domain.DocumentAttributeKind, predicate func(domain.DocumentAttribute) bool) bool { + for _, attribute := range document.Attributes { + if attribute.Kind == target && (predicate == nil || predicate(attribute)) { + return true + } + } + return false + } + switch kind { + case "document": + return document.ID > 0 + case "animation": + return has(domain.DocAttrAnimated, nil) + case "audio": + return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return !a.Voice }) + case "sticker": + return document.IsSticker() + case "video": + return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return !a.RoundMessage }) + case "video_note": + return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return a.RoundMessage }) + case "voice": + return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return a.Voice }) + default: + return false + } +} + func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) { switch { case chatID > 0: @@ -400,6 +792,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, return self.Message, nil } +func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) { + if r == nil || botID == 0 || !r.userIsBot(ctx, botID) { + return false, errors.New("BOT_INVALID") + } + if text == "" { + return false, errors.New("MESSAGE_EMPTY") + } + if utf8.RuneCountInString(text) > domain.MaxMessageTextLength { + return false, errors.New("MESSAGE_TOO_LONG") + } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + req := &tg.MessagesEditInlineBotMessageRequest{ + ID: tgInputBotInlineMessageID(inlineMessageID), + NoWebpage: disableWebPagePreview, + } + req.SetMessage(text) + if len(entities) > 0 { + req.SetEntities(tgMessageEntities(entities)) + } + if setReplyMarkup { + wire := tgReplyMarkup(replyMarkup) + if wire == nil { + wire = &tg.ReplyInlineMarkup{} + } + req.SetReplyMarkup(wire) + } + return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req) +} + // BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so // the target user's MTProto clients observe the normal delete update. func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) { @@ -440,12 +863,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal if cacheTime < 0 { cacheTime = 0 } - r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{ + resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{ Alert: showAlert, Message: text, URL: url, CacheTime: cacheTime, }) + if resolveErr != nil { + return false, resolveErr + } + if !resolved { + return false, errors.New("QUERY_ID_INVALID") + } return true, nil } diff --git a/internal/rpc/botapi_gateway_test.go b/internal/rpc/botapi_gateway_test.go index 277a4ad6..902ec78b 100644 --- a/internal/rpc/botapi_gateway_test.go +++ b/internal/rpc/botapi_gateway_test.go @@ -2,11 +2,14 @@ package rpc import ( "context" + "strconv" "strings" "testing" "time" "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" appbots "telesrv/internal/app/bots" @@ -17,6 +20,170 @@ import ( "telesrv/internal/store/memory" ) +func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + data := []byte("private-confirm") + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data, + }}}} + sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{ + SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID, + RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if _, err := fixture.router.resolveBotCallbackQuery( + fixture.ctx, + fixture.owner.ID, + domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID}, + sent.RecipientMessage.ID, + []byte("forged-callback-data"), + ); !tgerr.Is(err, "DATA_INVALID") { + t.Fatalf("forged callback data err = %v, want DATA_INVALID", err) + } + ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second) + defer cancel() + answerCh := make(chan struct { + answer *tg.MessagesBotCallbackAnswer + err error + }, 1) + go func() { + req := &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash}, + MsgID: sent.RecipientMessage.ID, + } + req.SetData(data) + answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req) + answerCh <- struct { + answer *tg.MessagesBotCallbackAnswer + err error + }{answer: answer, err: err} + }() + + event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID) + if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out { + t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID) + } + callback := event.BotCallbackQuery + if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) || + callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) { + t.Fatalf("callback = %+v", callback) + } + if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok { + t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err) + } + select { + case result := <-answerCh: + if result.err != nil || result.answer == nil || result.answer.Message != "accepted" { + t.Fatalf("callback answer = %+v err=%v", result.answer, result.err) + } + case <-ctx.Done(): + t.Fatal("callback answer did not unblock requester") + } +} + +func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") { + t.Fatalf("unknown answer = ok=%v err=%v", ok, err) + } + item := domain.BotAPIUpdate{ + ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, + Date: 100, + Callback: &domain.BotCallbackQuery{ + ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3, + }, + } + if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok { + t.Fatal("callback at answer deadline remained deliverable") + } +} + +func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) { + now := time.Unix(200, 0) + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988} + item := domain.BotAPIUpdate{ + ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()), + Callback: &domain.BotCallbackQuery{ + ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, + Data: []byte("inline"), InlineMessage: inline, + }, + } + event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now) + if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) || + event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline { + t.Fatalf("inline callback event=%#v ok=%v", event, ok) + } +} + +func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + data := []byte("group-confirm") + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data, + }}}} + sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{ + UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002, + Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second) + defer cancel() + answerCh := make(chan error, 1) + go func() { + req := &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash}, + MsgID: sent.Message.ID, + } + req.SetData(data) + _, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req) + answerCh <- err + }() + + event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID) + callback := event.BotCallbackQuery + if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || + callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out { + t.Fatalf("group callback event = %+v", event) + } + if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil { + t.Fatalf("BotAPIAnswerCallbackQuery: %v", err) + } + select { + case err := <-answerCh: + if err != nil { + t.Fatalf("group callback answer: %v", err) + } + case <-ctx.Done(): + t.Fatal("group callback answer did not unblock requester") + } +} + +func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent { + t.Helper() + for { + events, err := router.BotAPIUpdates(ctx, botID, 0) + if err != nil { + t.Fatalf("BotAPIUpdates: %v", err) + } + for _, event := range events { + if event.Type == domain.UpdateEventBotCallbackQuery { + return event + } + } + select { + case <-ctx.Done(): + t.Fatal("callback query did not reach Bot API queue") + case <-time.After(10 * time.Millisecond): + } + } +} + func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { }, zaptest.NewLogger(t), clock.System) chatID := -botAPIChannelChatIDBase - created.Channel.ID - msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0) + replyKeyboard := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + } + msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0) if err != nil { t.Fatalf("BotAPISendMessage: %v", err) } @@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { if err != nil { t.Fatalf("GetHistory: %v", err) } - if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body { + if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body || + history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard || + history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" { t.Fatalf("history messages = %+v, want bot channel message", history.Messages) } if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) { diff --git a/internal/rpc/botapi_update_queue.go b/internal/rpc/botapi_update_queue.go index 5aec6a53..b0492977 100644 --- a/internal/rpc/botapi_update_queue.go +++ b/internal/rpc/botapi_update_queue.go @@ -2,11 +2,16 @@ package rpc import ( "context" + "errors" + "time" "telesrv/internal/domain" ) -const botAPIGetUpdatesLimit = 100 +const ( + botAPIGetUpdatesLimit = 100 + botAPIMaxNegativeOffset = 10000 +) type botAPIChannelBotMemberProvider interface { ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) @@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in return nil, nil } fromID := int64(1) - if offset > 0 { + var items []domain.BotAPIUpdate + if offset < 0 { + if offset < -botAPIMaxNegativeOffset { + return nil, errors.New("OFFSET_INVALID") + } + var err error + items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit) + if err != nil { + return nil, err + } + if len(items) > 0 && items[0].ID > 1 { + if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil { + return nil, err + } + } + } else if offset > 0 { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil { return nil, err } - fromID = offset - } else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil { - return nil, err - } else if found { - fromID = confirmed + 1 } - items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit) - if err != nil { - return nil, err + if offset >= 0 { + confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID) + if err != nil { + return nil, err + } + if found { + fromID = confirmed + 1 + } + items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit) + if err != nil { + return nil, err + } } if len(items) == 0 { return nil, nil } - events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items) + events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now()) if leadingSkipped > 0 { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil { return nil, err @@ -46,13 +70,19 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in return r.enrichUpdateEvents(ctx, botID, events), nil } -func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) { +func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) { privateIDs := make([]int, 0) privateSeen := make(map[int]struct{}) channelIDs := make(map[int64][]int) channelSeen := make(map[int64]map[int]struct{}) for _, item := range items { - if _, ok := botAPIQueuedUpdateKind(botID, item); !ok { + if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok { + continue + } + if item.Ephemeral != nil { + continue + } + if item.Callback != nil && item.Callback.InlineMessage != nil { continue } switch item.Peer.Type { @@ -79,7 +109,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item events := make([]domain.UpdateEvent, 0, len(items)) leadingSkipped := int64(0) for _, item := range items { - event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages) + event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now) if !ok { if len(events) == 0 { leadingSkipped = item.ID @@ -101,7 +131,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i } out := make(map[int]domain.Message, len(list.Messages)) for _, msg := range list.Messages { - if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) { + if msg.ID <= 0 || msg.OwnerUserID != botID { continue } out[msg.ID] = msg @@ -127,10 +157,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i if msg.ID <= 0 || msg.Deleted || msg.Action != nil { continue } - projected := botAPIMessageFromChannel(botID, msg) - if projected.Out || !botAPIMessageProjectable(projected) { - continue - } byID[msg.ID] = msg } if len(byID) > 0 { @@ -140,14 +166,40 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i return out } -func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) { - if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 { +func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) { + if item.ID <= 0 || item.BotUserID != botID { return "", false } eventType, ok := botAPIUpdateEventType(item.Kind) if !ok { return "", false } + if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) { + return "", false + } + if item.Kind == domain.BotAPIUpdateCallbackQuery { + if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) { + return "", false + } + cb := item.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 || + cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen { + return "", false + } + if cb.InlineMessage != nil { + inline := cb.InlineMessage + if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) || + inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 { + return "", false + } + return eventType, true + } + if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID { + return "", false + } + } else if item.MessageID <= 0 { + return "", false + } switch item.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: if item.Peer.ID <= 0 { @@ -159,26 +211,89 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat return eventType, true } -func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) { - eventType, ok := botAPIQueuedUpdateKind(botID, item) +func botAPIQueuedEphemeralValid(botID int64, item domain.BotAPIUpdate, now time.Time) bool { + if item.Ephemeral == nil { + return true + } + message := item.Ephemeral.Message + if item.Ephemeral.Validate() != nil || item.SourcePts != 0 || item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID <= 0 || + message.ID != item.MessageID || message.Peer != item.Peer || message.Expired(now) || + message.SenderUserID <= 0 || message.ReceiverUserID <= 0 { + return false + } + if item.Kind == domain.BotAPIUpdateCallbackQuery { + return message.SenderUserID == botID + } + return message.ReceiverUserID == botID +} + +func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) { + eventType, ok := botAPIQueuedUpdateKind(botID, item, now) if !ok { return domain.UpdateEvent{}, false } + if item.Ephemeral != nil { + message := item.Ephemeral.EphemeralMessage() + event := domain.UpdateEvent{ + UserID: botID, Type: eventType, Date: item.Date, Peer: item.Peer, + BotAPIUpdateID: item.ID, EphemeralMessage: &message, + } + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + event.BotCallbackQuery = &callback + } + return event, true + } + if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + inline := *item.Callback.InlineMessage + callback.InlineMessage = &inline + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + BotCallbackQuery: &callback, + }, true + } switch item.Peer.Type { case domain.PeerTypeUser: msg, found := privateMessages[item.MessageID] if !found { return domain.UpdateEvent{}, false } + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: item.Peer, + Message: msg, + BotCallbackQuery: &callback, + }, true + } + if msg.Out || !botAPIMessageProjectable(msg) { + return domain.UpdateEvent{}, false + } msg.Pts = int(item.ID) return domain.UpdateEvent{ - UserID: botID, - Type: eventType, - Pts: int(item.ID), - PtsCount: 1, - Date: item.Date, - Peer: msg.Peer, - Message: msg, + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: msg.Peer, + Message: msg, }, true case domain.PeerTypeChannel: msg, found := channelMessages[item.Peer.ID][item.MessageID] @@ -186,15 +301,34 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, return domain.UpdateEvent{}, false } projected := botAPIMessageFromChannel(botID, msg) + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: item.Peer, + Message: projected, + BotCallbackQuery: &callback, + }, true + } + if projected.Out || !botAPIMessageProjectable(projected) { + return domain.UpdateEvent{}, false + } projected.Pts = int(item.ID) return domain.UpdateEvent{ - UserID: botID, - Type: eventType, - Pts: int(item.ID), - PtsCount: 1, - Date: item.Date, - Peer: projected.Peer, - Message: projected, + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: projected.Peer, + Message: projected, }, true default: return domain.UpdateEvent{}, false @@ -207,6 +341,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType return domain.UpdateEventNewMessage, true case domain.BotAPIUpdateEditedMessage: return domain.UpdateEventEditMessage, true + case domain.BotAPIUpdateCallbackQuery: + return domain.UpdateEventBotCallbackQuery, true default: return "", false } @@ -410,7 +546,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool { return media.Photo != nil case domain.MessageMediaKindDocument: return media.Document != nil + case domain.MessageMediaKindContact: + return media.Contact != nil + case domain.MessageMediaKindGeo: + return media.Geo != nil + case domain.MessageMediaKindVenue: + return media.Venue != nil + case domain.MessageMediaKindPoll: + return media.Poll != nil + case domain.MessageMediaKindGeoLive: + return media.GeoLive != nil + case domain.MessageMediaKindService: + if media.ServiceAction == nil { + return false + } + switch media.ServiceAction.Kind { + case domain.MessageServiceActionWebViewDataSent: + return media.ServiceAction.WebViewData != nil + case domain.MessageServiceActionRequestedPeer: + return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer) + default: + return false + } default: return false } } + +func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool { + if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity { + return false + } + details := make(map[domain.Peer]struct{}, len(action.Details)) + for _, detail := range action.Details { + if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) { + return false + } + details[detail.Peer] = struct{}{} + } + requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested + allUsers := true + for _, peer := range action.Peers { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return false + } + if requiresDetails { + if _, ok := details[peer]; !ok { + return false + } + } + allUsers = allUsers && peer.Type == domain.PeerTypeUser + } + return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel) +} diff --git a/internal/rpc/botapi_update_queue_projection_test.go b/internal/rpc/botapi_update_queue_projection_test.go new file mode 100644 index 00000000..d86d99d8 --- /dev/null +++ b/internal/rpc/botapi_update_queue_projection_test.go @@ -0,0 +1,70 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) { + validRequestedUsers := &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}}, + } + tests := []struct { + name string + media *domain.MessageMedia + want bool + }{ + {"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true}, + {"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true}, + {"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true}, + {"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true}, + {"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true}, + {"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{}, + }}, true}, + {"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers, + }}, true}, + {"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true, + }, + }}, false}, + {"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}}, + }, + }}, false}, + {"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{}, + }}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := botAPIMessageMediaProjectable(tt.media); got != tt.want { + t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media) + } + }) + } +} + +func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) { + users := map[int64]struct{}{} + channels := map[int64]struct{}{} + collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{ + {Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}, + }}, + }, + }}, 0, users, channels) + if _, ok := users[1001]; !ok { + t.Fatalf("requested user refs=%v", users) + } + if _, ok := channels[55]; !ok { + t.Fatalf("requested channel refs=%v", channels) + } +} diff --git a/internal/rpc/bots.go b/internal/rpc/bots.go index 5d714f18..10bed311 100644 --- a/internal/rpc/bots.go +++ b/internal/rpc/bots.go @@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool { func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { out := make([]domain.BotCommand, 0, len(in)) for _, c := range in { - out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description}) + out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral}) } return out } @@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { func tgBotCommands(in []domain.BotCommand) []tg.BotCommand { out := make([]tg.BotCommand, 0, len(in)) for _, c := range in { - out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description}) + out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral}) } return out } diff --git a/internal/rpc/bots_callback.go b/internal/rpc/bots_callback.go index 948393e8..4960253c 100644 --- a/internal/rpc/bots_callback.go +++ b/internal/rpc/bots_callback.go @@ -1,12 +1,14 @@ package rpc import ( + "bytes" "context" "time" "unicode/utf8" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap" "telesrv/internal/domain" ) @@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") } func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") } -// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery -// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。 +type privateMessageByUIDService interface { + GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) +} + +// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query +// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的 +// setBotCallbackAnswer/answerCallbackQuery,或超时回 BOT_RESPONSE_TIMEOUT。 func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes if err != nil { return nil, err } - // callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。 - if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) { - return nil, dataInvalidErr() - } - botUserID := peer.ID // game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西), // 不挂起、不推送(避免给 bot 投递无法处理的 game query)。 if req.Game { @@ -46,47 +48,218 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes if !hasData { return nil, dataInvalidErr() } + if len(data) > domain.MaxCallbackDataLen { + return nil, dataInvalidErr() + } // 校验目标消息存在于请求者自己的盒、且对端正是该 bot。 if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { return nil, messageIDInvalidErr() } - msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID) + callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data) if err != nil { + return nil, err + } + botUserID := callback.BotUserID + + queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout) + if err != nil { + r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err)) return nil, internalErr() } - if !ok || msg.Peer != peer { - return nil, messageIDInvalidErr() + defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID) + callback.ID = queryID + + // Bot API callback_query shares the dedicated durable update_id queue with message and + // edited_message. The callback answer waiter itself remains ephemeral/process-local. + if r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: botUserID, + Kind: domain.BotAPIUpdateCallbackQuery, + Peer: callback.Peer, + MessageID: callback.MessageID, + Date: int(r.clock.Now().Unix()), + Callback: &callback, + }); err != nil { + r.log.Warn("enqueue bot api callback query", + zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(botUserID) + } } - queryID, pending := r.callbacks.register(botUserID, userID) - defer r.callbacks.deregister(queryID) - - // updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给 - // bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。 - // MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。 - update := &tg.UpdateBotCallbackQuery{ - QueryID: queryID, - UserID: userID, - Peer: &tg.PeerUser{UserID: userID}, - MsgID: req.MsgID, - ChatInstance: chatInstanceFor(botUserID, userID), + // updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference);私聊 MessageID + // 已翻译为 bot 视角 box id,channel 使用共享 message id。 + var update tg.UpdateClass + if callback.InlineMessage != nil { + inline := &tg.UpdateInlineBotCallbackQuery{ + QueryID: queryID, UserID: userID, + MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance, + } + inline.SetData(data) + update = inline + } else { + direct := &tg.UpdateBotCallbackQuery{ + QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer), + MsgID: callback.MessageID, ChatInstance: callback.ChatInstance, + } + direct.SetData(data) + update = direct } - update.SetData(data) r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{ Updates: []tg.UpdateClass{update}, Date: int(r.clock.Now().Unix()), }) + return r.waitBotCallbackAnswer(ctx, botUserID, queryID, pending) +} + +func (r *Router) waitBotCallbackAnswer(ctx context.Context, botUserID, queryID int64, pending *pendingCallback) (*tg.MessagesBotCallbackAnswer, error) { waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout) defer cancel() - select { - case ans := <-pending.ch: - return tgBotCallbackAnswer(ans), nil - case <-waitCtx.Done(): - return nil, botResponseTimeoutErr() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + select { + case ans := <-pending.ch: + return tgBotCallbackAnswer(ans), nil + case <-ticker.C: + ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID) + if err != nil { + r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err)) + continue + } + if found { + return tgBotCallbackAnswer(ans), nil + } + case <-waitCtx.Done(): + return nil, botResponseTimeoutErr() + } } } +// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message +// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline +// ids and therefore remain an explicit blocked path instead of being misrouted here. +func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) { + if peer.Type == domain.PeerTypeUser { + msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID) + if err != nil { + return domain.BotCallbackQuery{}, internalErr() + } + if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + if msg.ViaBotID != 0 { + if !r.userIsBot(ctx, msg.ViaBotID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64) + if !ok { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.ViaBotID, UserID: userID, + ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...), + InlineMessage: domainInlineMessageID(inlineID), + }, nil + } + if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + provider, ok := r.deps.Messages.(privateMessageByUIDService) + if !ok || msg.UID == 0 { + return domain.BotCallbackQuery{}, internalErr() + } + botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID) + if err != nil { + return domain.BotCallbackQuery{}, internalErr() + } + if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID || + botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.From.ID, + UserID: userID, + Peer: botMessage.Peer, + MessageID: botMessage.ID, + ChatInstance: chatInstanceFor(msg.From.ID, userID), + Data: append([]byte(nil), data...), + }, nil + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return domain.BotCallbackQuery{}, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID}) + if err != nil { + return domain.BotCallbackQuery{}, channelInvalidErr(err) + } + if len(history.Messages) != 1 { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + msg := history.Messages[0] + if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + if msg.ViaBotID != 0 { + if !r.userIsBot(ctx, msg.ViaBotID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64) + if !ok { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.ViaBotID, UserID: userID, + ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...), + InlineMessage: domainInlineMessageID(inlineID), + }, nil + } + if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.SenderUserID, + UserID: userID, + Peer: peer, + MessageID: msg.ID, + ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer), + Data: append([]byte(nil), data...), + }, nil +} + +func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID { + if id == nil { + return nil + } + return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash} +} + +func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass { + return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash} +} + +func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool { + if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline { + return false + } + for _, row := range markup.Inline { + for _, button := range row { + if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) { + return true + } + } + } + return false +} + // onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的 // getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。 func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) { @@ -106,7 +279,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes } // resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否 // 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。 - r.callbacks.resolve(botID, req.QueryID, ans) + if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil { + return false, internalErr() + } return true, nil } diff --git a/internal/rpc/bots_longtail.go b/internal/rpc/bots_longtail.go index 097cb8b8..7cfbe292 100644 --- a/internal/rpc/bots_longtail.go +++ b/internal/rpc/bots_longtail.go @@ -422,7 +422,7 @@ func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUp if !ok { return false, userPermissionDeniedErr() } - u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until) + u, err := svc.UpdateEmojiStatus(ctx, target.ID, domain.UserEmojiStatus{DocumentID: documentID, Until: until}) if err != nil { if errors.Is(err, domain.ErrPremiumRequired) { return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") @@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg case *tg.InputKeyboardButtonRequestPeer: out.ButtonID = b.ButtonID out.Text = strings.TrimSpace(b.Text) - out.PeerType = requestPeerTypeName(b.PeerType) + out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType) out.MaxQuantity = b.MaxQuantity + out.NameRequested = b.NameRequested + out.UsernameRequested = b.UsernameRequested + out.PhotoRequested = b.PhotoRequested case *tg.KeyboardButtonRequestPeer: out.ButtonID = b.ButtonID out.Text = strings.TrimSpace(b.Text) - out.PeerType = requestPeerTypeName(b.PeerType) + out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType) out.MaxQuantity = b.MaxQuantity default: return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() @@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key return &tg.KeyboardButtonRequestPeer{ Text: button.Text, ButtonID: button.ButtonID, - PeerType: tgRequestPeerType(button.PeerType), + PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter), MaxQuantity: button.MaxQuantity, } } diff --git a/internal/rpc/callback_registry.go b/internal/rpc/callback_registry.go index 436fc6bd..99034c45 100644 --- a/internal/rpc/callback_registry.go +++ b/internal/rpc/callback_registry.go @@ -1,22 +1,29 @@ package rpc import ( + "context" "crypto/rand" "encoding/binary" + "fmt" "hash/fnv" "sync" "sync/atomic" + "time" + + "go.uber.org/zap" "telesrv/internal/domain" + "telesrv/internal/store" ) -// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer -// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经 -// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道 -// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。 +// callbackRegistry keeps local waiter channels and mirrors ownership/answers to +// a short-lived shared store. The shared CAS is the source of truth when wired: +// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes +// without accepting two answers or trusting a process-local owner map. type callbackRegistry struct { mu sync.Mutex pending map[int64]*pendingCallback + shared store.BotCallbackRegistryStore } type pendingCallback struct { @@ -26,35 +33,77 @@ type pendingCallback struct { userID int64 } -func newCallbackRegistry() *callbackRegistry { - return &callbackRegistry{pending: make(map[int64]*pendingCallback)} +func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry { + var sharedStore store.BotCallbackRegistryStore + if len(shared) > 0 { + sharedStore = shared[0] + } + return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore} } // register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须 // defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。 func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) { - p := &pendingCallback{ - ch: make(chan domain.BotCallbackAnswer, 1), - done: make(chan struct{}), - botUserID: botUserID, - userID: userID, - } - c.mu.Lock() - defer c.mu.Unlock() - var queryID int64 - for { - queryID = randomNonZeroInt64() - if _, exists := c.pending[queryID]; !exists { - break + queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout) + return queryID, pending +} + +func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) { + for attempts := 0; attempts < 32; attempts++ { + p := &pendingCallback{ + ch: make(chan domain.BotCallbackAnswer, 1), + done: make(chan struct{}), + botUserID: botUserID, + userID: userID, } + c.mu.Lock() + queryID := randomNonZeroInt64() + if _, exists := c.pending[queryID]; exists { + c.mu.Unlock() + continue + } + c.pending[queryID] = p + c.mu.Unlock() + if c.shared == nil { + return queryID, p, nil + } + created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{ + QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now, + }, ttl) + if err != nil { + c.removeLocal(queryID) + return 0, nil, err + } + if created { + return queryID, p, nil + } + c.removeLocal(queryID) } - c.pending[queryID] = p - return queryID, p + return 0, nil, fmt.Errorf("allocate bot callback query id") } // deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在 // select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。 func (c *callbackRegistry) deregister(queryID int64) { + c.deregisterContext(context.Background(), 0, queryID) +} + +func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) { + c.mu.Lock() + if p, ok := c.pending[queryID]; ok { + if botUserID == 0 { + botUserID = p.botUserID + } + delete(c.pending, queryID) + close(p.done) + } + c.mu.Unlock() + if c.shared != nil && botUserID > 0 { + _ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID) + } +} + +func (c *callbackRegistry) removeLocal(queryID int64) { c.mu.Lock() if p, ok := c.pending[queryID]; ok { delete(c.pending, queryID) @@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int { // resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID // 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。 func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { + resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans) + return resolved +} + +func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) { + if c.shared != nil { + resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans) + if err != nil || !resolved { + return resolved, err + } + c.deliver(callerBotID, queryID, ans) + return true, nil + } + return c.deliver(callerBotID, queryID, ans), nil +} + +func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { c.mu.Lock() p, ok := c.pending[queryID] if !ok || p.botUserID != callerBotID { @@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal return true } +func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) { + if c.shared == nil { + return domain.BotCallbackAnswer{}, false, nil + } + return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID) +} + +func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) { + if r == nil || r.callbacks == nil || r.callbacks.shared == nil { + return + } + for ctx.Err() == nil { + err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) { + r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer) + }) + if ctx.Err() != nil { + return + } + if err != nil && r.log != nil { + r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err)) + } + timer := time.NewTimer(time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + // randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止 // 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的 // 撞键复核会再保证唯一性),绝不卡住整个 registry。 @@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 { } return v } + +// chatInstanceForPeer extends the stable hash to non-private chats without allowing a +// channel id to collide with a numerically equal private user id. +func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 { + h := fnv.New64a() + var buf [17]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID)) + switch peer.Type { + case domain.PeerTypeChannel: + buf[16] = 2 + default: + buf[16] = 1 + } + _, _ = h.Write(buf[:]) + v := int64(h.Sum64()) + if v == 0 { + return 1 + } + return v +} diff --git a/internal/rpc/channel_fanout_dispatcher.go b/internal/rpc/channel_fanout_dispatcher.go index 076351a4..827aebd6 100644 --- a/internal/rpc/channel_fanout_dispatcher.go +++ b/internal/rpc/channel_fanout_dispatcher.go @@ -993,6 +993,22 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i }) } +// enqueueMonoforumMessageFanout only targets the subscriber sub-dialog and active parent-channel +// admins. A monoforum has no ordinary members, so member recomputation would either drop the +// message or leak it to an invalid historical membership. +func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) { + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID}) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients, + 0, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res) + }) +} + // skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。 func skipDeliverySet(ids []int64) map[int64]struct{} { if len(ids) == 0 { diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go index d1c34e50..93628634 100644 --- a/internal/rpc/channels_core.go +++ b/internal/rpc/channels_core.go @@ -2,9 +2,12 @@ package rpc import ( "context" - "github.com/iamxvbaba/td/tg" - "telesrv/internal/domain" + "errors" "unicode/utf8" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" ) func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) { @@ -71,37 +74,54 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne channelIDs := make([]int64, 0, len(ids)) for _, input := range ids { ref, ok := inputChannelRef(input) - if !ok || ref.ID == 0 || r.deps.Channels == nil { + if !ok || ref.ID == 0 { continue } refs = append(refs, ref) channelIDs = append(channelIDs, ref.ID) } - if len(channelIDs) == 0 || r.deps.Channels == nil { + if len(channelIDs) == 0 || (r.deps.Channels == nil && r.deps.Communities == nil) { return &tg.MessagesChats{}, nil } - views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs) - if err != nil { - return nil, internalErr() + var views []domain.ChannelView + if r.deps.Channels != nil { + views, err = r.deps.Channels.GetChannels(ctx, userID, channelIDs) + if err != nil { + return nil, internalErr() + } } byID := make(map[int64]domain.ChannelView, len(views)) for _, view := range views { byID[view.Channel.ID] = view } + communityByID := make(map[int64]domain.CommunityView) + if r.deps.Communities != nil { + communityViews, err := r.deps.Communities.GetMany(ctx, userID, channelIDs) + if err != nil { + return nil, internalErr() + } + for _, view := range communityViews { + communityByID[view.Community.ID] = view + } + } chats := make([]tg.ChatClass, 0, len(refs)) for _, ref := range refs { - view, ok := byID[ref.ID] - if !ok || !inputChannelAccessHashMatches(ref, view.Channel) { + if view, ok := communityByID[ref.ID]; ok { + if !ref.CheckAccessHash || ref.AccessHash == view.Community.AccessHash { + chats = append(chats, tgCommunityChat(view)) + } continue } - chats = append(chats, tgChannelChatForView(userID, view)) + if view, ok := byID[ref.ID]; ok && inputChannelAccessHashMatches(ref, view.Channel) { + chats = append(chats, tgChannelChatForView(userID, view)) + } } r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) return &tg.MessagesChats{Chats: chats}, nil } func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return &tg.MessagesChatFull{}, nil } userID, _, err := r.currentUserID(ctx) @@ -112,6 +132,34 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha if !ok { return nil, channelInvalidErr(domain.ErrChannelInvalid) } + if r.deps.Communities != nil { + view, communityErrValue := r.deps.Communities.Get(ctx, userID, ref.ID) + if communityErrValue == nil { + if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash { + return nil, channelInvalidErr(domain.ErrCommunityPrivate) + } + if settings := r.userNotifySettings(ctx, userID); len(settings) > 0 { + if setting, ok := settings[domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID}]; ok { + copy := setting.Clone() + view.State.NotifySettings = © + } + } + return &tg.MessagesChatFull{ + FullChat: tgCommunityFull(view), + Chats: tgCommunityHydratedChats(userID, view), + Users: tgUsers(view.Users), + }, nil + } + if errors.Is(communityErrValue, domain.ErrCommunityPrivate) { + return nil, communityErr(communityErrValue) + } + if !errors.Is(communityErrValue, domain.ErrCommunityInvalid) { + return nil, communityErr(communityErrValue) + } + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } loadEpoch := r.channelFullProjectionCache.LoadEpoch() if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok { if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) { diff --git a/internal/rpc/channels_legacy_chat.go b/internal/rpc/channels_legacy_chat.go index 9b15a651..2ec6cbec 100644 --- a/internal/rpc/channels_legacy_chat.go +++ b/internal/rpc/channels_legacy_chat.go @@ -227,7 +227,7 @@ func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEd } func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return false, notImplementedErr() } if utf8.RuneCountInString(req.About) > maxChannelAboutLength { @@ -237,6 +237,22 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd if err != nil { return false, internalErr() } + if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok { + if err != nil { + return false, err + } + view, changed, err := r.deps.Communities.EditAbout(ctx, userID, community.Community.ID, req.About) + if err != nil { + return false, communityErr(err) + } + if changed { + r.pushCommunityState(ctx, userID, view) + } + return true, nil + } + if r.deps.Channels == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) if err != nil { return false, err @@ -255,13 +271,26 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd } func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return nil, notImplementedErr() } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } + if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok { + if err != nil { + return nil, err + } + view, changed, err := r.deps.Communities.EditDefaultBannedRights(ctx, userID, community.Community.ID, domainChannelBannedRights(req.BannedRights)) + if err != nil { + return nil, communityErr(err) + } + return r.communityMutationUpdates(ctx, userID, view, changed), nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) if err != nil { return nil, err diff --git a/internal/rpc/channels_members.go b/internal/rpc/channels_members.go index 7ec4b87d..ea9d69b7 100644 --- a/internal/rpc/channels_members.go +++ b/internal/rpc/channels_members.go @@ -23,6 +23,13 @@ func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg if req.ByLocation { return &tg.MessagesChats{}, nil } + if req.ForCommunityPeer { + channels, err := r.deps.Channels.ListCommunityLinkableChannels(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil + } channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID) if err != nil { return nil, internalErr() @@ -107,7 +114,7 @@ func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.Channel } func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return &tg.ChannelsChannelParticipants{}, nil } userID, _, err := r.currentUserID(ctx) @@ -122,6 +129,26 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { return nil, limitInvalidErr() } + if community, isCommunity, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); isCommunity { + if err != nil { + return nil, err + } + list, err := r.deps.Communities.Participants(ctx, userID, community.Community.ID, filter, req.Offset, req.Limit) + if err != nil { + return nil, communityErr(err) + } + if req.Hash != 0 && list.Hash == req.Hash { + return &tg.ChannelsChannelParticipantsNotModified{}, nil + } + participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants)) + for _, member := range list.Participants { + participants = append(participants, tgCommunityMember(userID, member)) + } + return &tg.ChannelsChannelParticipants{Count: list.Count, Participants: participants, Chats: []tg.ChatClass{tgCommunityChat(community)}, Users: tgUsers(list.Users)}, nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit) if err != nil { return nil, channelInvalidErr(err) @@ -390,17 +417,13 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI } func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return nil, notImplementedErr() } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } target, found, err := r.userFromInput(ctx, userID, req.UserID) if err != nil { return nil, internalErr() @@ -408,6 +431,33 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd if !found || target.ID == 0 { return nil, peerIDInvalidErr() } + if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok { + if err != nil { + return nil, err + } + view, changed, err := r.deps.Communities.EditAdmin(ctx, userID, domain.CommunityEditAdminRequest{ + CommunityID: community.Community.ID, + UserID: target.ID, + Rights: domainChannelAdminRights(req.AdminRights), + Rank: req.Rank, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, communityErr(err) + } + updates := r.communityMutationUpdates(ctx, userID, view, changed) + if changed && target.ID != userID { + r.refreshAndPushCommunityState(ctx, target.ID, community.Community.ID, community.Community) + } + return updates, nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{ UserID: userID, ChannelID: channelID, diff --git a/internal/rpc/channels_settings.go b/internal/rpc/channels_settings.go index fde35b8e..3e185d0c 100644 --- a/internal/rpc/channels_settings.go +++ b/internal/rpc/channels_settings.go @@ -309,7 +309,7 @@ func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.Ch } func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return nil, notImplementedErr() } if !validChannelTitle(req.Title) { @@ -319,6 +319,19 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi if err != nil { return nil, internalErr() } + if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok { + if err != nil { + return nil, err + } + view, changed, err := r.deps.Communities.EditTitle(ctx, userID, community.Community.ID, req.Title) + if err != nil { + return nil, communityErr(err) + } + return r.communityMutationUpdates(ctx, userID, view, changed), nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) if err != nil { return nil, err @@ -354,7 +367,7 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi } func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return nil, notImplementedErr() } if req.Photo == nil { @@ -364,11 +377,24 @@ func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPh if err != nil { return nil, internalErr() } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo) if err != nil { return nil, err } - photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo) + if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok { + if err != nil { + return nil, err + } + view, changed, err := r.deps.Communities.SetPhoto(ctx, userID, community.Community.ID, photo, int(r.clock.Now().Unix())) + if err != nil { + return nil, communityErr(err) + } + return r.communityMutationUpdates(ctx, userID, view, changed), nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) if err != nil { return nil, err } diff --git a/internal/rpc/channels_stubs.go b/internal/rpc/channels_stubs.go index 791496e4..118aa7cb 100644 --- a/internal/rpc/channels_stubs.go +++ b/internal/rpc/channels_stubs.go @@ -61,13 +61,29 @@ func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.Channe } func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { + if r.deps.Channels == nil && r.deps.Communities == nil { return nil, notImplementedErr() } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } + if community, ok, err := r.maybeCommunityFromInput(ctx, userID, input); ok { + if err != nil { + return nil, err + } + view, _, err := r.deps.Communities.Delete(ctx, userID, community.Community.ID, int(r.clock.Now().Unix())) + if err != nil { + return nil, communityErr(err) + } + for _, serviceMessage := range view.ServiceMessages { + r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil) + } + return r.communityMutationUpdates(ctx, userID, view, true), nil + } + if r.deps.Channels == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } channelID, err := r.channelIDFromInput(ctx, userID, input) if err != nil { return nil, err @@ -669,6 +685,8 @@ func channelInvalidErr(err error) error { return tgerr400("CHAT_WRITE_FORBIDDEN") case errors.Is(err, domain.ErrChannelAdminRequired): return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrChannelMonoforumUnsupported): + return tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED") case errors.Is(err, domain.ErrUserAlreadyParticipant): return tgerr400("USER_ALREADY_PARTICIPANT") case errors.Is(err, domain.ErrReplyMessageIDInvalid): diff --git a/internal/rpc/communities.go b/internal/rpc/communities.go new file mode 100644 index 00000000..ec6434b4 --- /dev/null +++ b/internal/rpc/communities.go @@ -0,0 +1,480 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +const communitiesLayer = 228 + +func communityErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrCommunityPrivate): + return tgerr400("CHANNEL_PRIVATE") + case errors.Is(err, domain.ErrCommunityAdminRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrCommunityCreatorRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrCommunityPeersTooMuch): + return tgerr400("COMMUNITY_PEERS_TOO_MUCH") + case errors.Is(err, domain.ErrCommunityRequestCreated): + return tgerr400("COMMUNITY_REQUEST_CREATED") + case errors.Is(err, domain.ErrCommunityRequestMissing): + return tgerr400("COMMUNITY_REQUEST_MISSING") + case errors.Is(err, domain.ErrCommunityPeerLinked): + return tgerr400("COMMUNITY_PEER_ALREADY_LINKED") + case errors.Is(err, domain.ErrCommunityPeerInvalid), errors.Is(err, domain.ErrCommunityParticipantInvalid): + return peerIDInvalidErr() + case errors.Is(err, domain.ErrChannelTitleInvalid): + return tgerr400("CHAT_TITLE_EMPTY") + case errors.Is(err, domain.ErrAboutTooLong): + return aboutTooLongErr() + case errors.Is(err, domain.ErrCommunityInvalid): + return channelInvalidErr(err) + default: + return internalErr() + } +} + +func (r *Router) communityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, error) { + if r.deps.Communities == nil { + return domain.CommunityView{}, notImplementedErr() + } + ref, ok := inputChannelRef(input) + if !ok || ref.ID == 0 { + return domain.CommunityView{}, channelInvalidErr(domain.ErrCommunityInvalid) + } + view, err := r.deps.Communities.Get(ctx, userID, ref.ID) + if err != nil { + return domain.CommunityView{}, communityErr(err) + } + if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash { + return domain.CommunityView{}, communityErr(domain.ErrCommunityPrivate) + } + return view, nil +} + +// maybeCommunityFromInput distinguishes a Community from an ordinary channel. +// IDs share one allocator, so ErrCommunityInvalid is the only fallthrough case. +func (r *Router) maybeCommunityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, bool, error) { + if r.deps.Communities == nil { + return domain.CommunityView{}, false, nil + } + ref, ok := inputChannelRef(input) + if !ok || ref.ID == 0 { + return domain.CommunityView{}, false, nil + } + view, err := r.deps.Communities.Get(ctx, userID, ref.ID) + if errors.Is(err, domain.ErrCommunityInvalid) { + return domain.CommunityView{}, false, nil + } + if err != nil { + return domain.CommunityView{}, true, communityErr(err) + } + if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash { + return domain.CommunityView{}, true, communityErr(domain.ErrCommunityPrivate) + } + return view, true, nil +} + +func (r *Router) maybeCommunityFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.CommunityView, bool, error) { + ref, ok := inputPeerChannelRef(peer) + if !ok { + return domain.CommunityView{}, false, nil + } + input := &tg.InputChannel{ChannelID: ref.ID, AccessHash: ref.AccessHash} + return r.maybeCommunityFromInput(ctx, userID, input) +} + +func (r *Router) communityPeerFromInput(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) { + peer, ok := r.domainPeerFromInputPeer(userID, input) + if peer.ID == 0 || (peer.Type != domain.PeerTypeChannel && peer.Type != domain.PeerTypeUser) { + return domain.Peer{}, peerIDInvalidErr() + } + if !ok { + return domain.Peer{}, peerIDInvalidErr() + } + if peer.Type == domain.PeerTypeChannel { + ref, ok := inputPeerChannelRef(input) + if !ok || r.deps.Channels == nil { + return domain.Peer{}, peerIDInvalidErr() + } + // A Community admin may approve or unlink a channel without being a + // member of that channel. Resolve the immutable base row for constructor + // and access_hash validation; aggregate authorization remains in the + // Community transaction (direct links still require channel admin rights, + // approvals require an existing validated request). + if resolver, ok := r.deps.Channels.(interface { + GetChannelByID(context.Context, int64) (domain.Channel, error) + }); ok { + channel, err := resolver.GetChannelByID(ctx, peer.ID) + if err != nil || channel.ID == 0 || channel.Deleted { + return domain.Peer{}, peerIDInvalidErr() + } + if ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, channel) { + return domain.Peer{}, channelInvalidErr(domain.ErrChannelPrivate) + } + } else if err := r.validateInputPeerChannelAccess(ctx, userID, input, peer.ID); err != nil { + return domain.Peer{}, err + } + } + return peer, nil +} + +func (r *Router) communityUpdates(view domain.CommunityView) *tg.Updates { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: tgUsers(view.Users), Chats: tgCommunityHydratedChats(view.Self.UserID, view), Date: int(r.clock.Now().Unix())} +} + +func (r *Router) pushCommunityState(ctx context.Context, userID int64, view domain.CommunityView) { + r.pushUserUpdates(ctx, userID, r.communityUpdates(view)) +} + +func (r *Router) refreshAndPushCommunityState(ctx context.Context, viewerUserID, communityID int64, fallback domain.Community) { + if viewerUserID == 0 || r.deps.Communities == nil { + return + } + view, err := r.deps.Communities.Get(ctx, viewerUserID, communityID) + if err == nil { + r.pushCommunityState(ctx, viewerUserID, view) + return + } + if errors.Is(err, domain.ErrCommunityPrivate) { + r.pushCommunityState(ctx, viewerUserID, domain.CommunityView{ + Community: fallback, + Self: domain.CommunityMember{CommunityID: communityID, UserID: viewerUserID}, + Forbidden: true, + }) + } +} + +func (r *Router) communityMutationUpdates(ctx context.Context, userID int64, view domain.CommunityView, changed bool) *tg.Updates { + out := r.communityUpdates(view) + if changed { + r.pushCommunityState(ctx, userID, view) + } + return out +} + +func (r *Router) withCommunityDialogList(ctx context.Context, userID int64, filter domain.DialogFilter, list domain.DialogList) (domain.DialogList, error) { + if LayerFrom(ctx) < communitiesLayer { + return list, nil + } + return r.withCollapsedCommunityDialogs(ctx, userID, filter, list) +} + +// withCollapsedCommunityDialogs applies the account-level Community dialog +// state without a wire-layer visibility decision. Business invariants such as +// the shared pinned limit use this path; RPC response construction must use +// withCommunityDialogList instead. +func (r *Router) withCollapsedCommunityDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, list domain.DialogList) (domain.DialogList, error) { + if r.deps.Communities == nil || (filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID) { + return list, nil + } + views, err := r.deps.Communities.ListJoined(ctx, userID) + if err != nil { + return domain.DialogList{}, err + } + for _, view := range views { + if !view.State.Collapsed || (filter.PinnedOnly && !view.State.Pinned) || (filter.ExcludePinned && view.State.Pinned) { + continue + } + list.Communities = append(list.Communities, view) + list.Count++ + } + return list, nil +} + +func (r *Router) communityDialogPeerFromInput(ctx context.Context, userID int64, input tg.InputDialogPeerClass) (domain.CommunityView, bool, error) { + peer, ok := input.(*tg.InputDialogPeerCommunity) + if !ok || peer == nil || peer.Community == nil { + return domain.CommunityView{}, false, nil + } + view, err := r.communityFromInput(ctx, userID, peer.Community) + return view, true, err +} + +func (r *Router) onCommunitiesCreate(ctx context.Context, req *tg.CommunitiesCreateRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Communities == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.communityPeerFromInput(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + visibility := domain.CommunityPeerVisible + if req.Hidden { + visibility = domain.CommunityPeerHidden + } + view, err := r.deps.Communities.Create(ctx, userID, domain.CreateCommunityRequest{Title: req.Title, About: req.About, InitialPeer: peer, Visibility: visibility, Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, communityErr(err) + } + for _, serviceMessage := range view.ServiceMessages { + r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil) + } + return r.communityUpdates(view), nil +} + +func (r *Router) emitCommunityLinkService(ctx context.Context, actorUserID int64, result domain.CommunityTogglePeerLinkResult) { + if result.ServiceMessage == nil { + return + } + r.enqueueChannelMessageFanout(ctx, actorUserID, *result.ServiceMessage, nil) +} + +func (r *Router) onCommunitiesTogglePeerLink(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (bool, error) { + if req == nil || r.deps.Communities == nil { + return false, notImplementedErr() + } + actions := 0 + if req.Visible { + actions++ + } + if req.Hidden { + actions++ + } + if req.Deleted { + actions++ + } + if actions != 1 { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return false, err + } + peer, err := r.communityPeerFromInput(ctx, userID, req.Peer) + if err != nil { + return false, err + } + visibility := domain.CommunityPeerVisible + if req.Hidden { + visibility = domain.CommunityPeerHidden + } + result, err := r.deps.Communities.TogglePeerLink(ctx, userID, domain.CommunityTogglePeerLinkRequest{CommunityID: view.Community.ID, Peer: peer, Visibility: visibility, Deleted: req.Deleted, Date: int(r.clock.Now().Unix())}) + if err != nil { + return false, communityErr(err) + } + if result.RequestCreated { + return false, tgerr400("COMMUNITY_REQUEST_CREATED") + } + r.emitCommunityLinkService(ctx, userID, result) + r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community) + return true, nil +} + +func (r *Router) onCommunitiesGetJoined(ctx context.Context) (tg.MessagesChatsClass, error) { + if r.deps.Communities == nil { + return &tg.MessagesChats{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + views, err := r.deps.Communities.ListJoined(ctx, userID) + if err != nil { + return nil, communityErr(err) + } + return &tg.MessagesChats{Chats: tgCommunityChats(views)}, nil +} + +func (r *Router) onCommunitiesToggleCollapsed(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return nil, err + } + wasPinned := view.State.Pinned + view, changed, err := r.deps.Communities.SetCollapsed(ctx, userID, view.Community.ID, req.Collapsed) + if err != nil { + return nil, communityErr(err) + } + out := r.communityUpdates(view) + if changed && !req.Collapsed && wasPinned { + out.Updates = append(out.Updates, &tg.UpdateDialogPinned{Peer: &tg.DialogPeerCommunity{CommunityID: view.Community.ID}}) + } + if changed { + r.pushCommunityState(ctx, userID, view) + } + return out, nil +} + +func (r *Router) onCommunitiesGetPeerLinkRequests(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (*tg.CommunitiesPeerLinkRequests, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return nil, err + } + page, err := r.deps.Communities.ListPeerLinkRequests(ctx, userID, view.Community.ID, req.Offset, req.Limit) + if err != nil { + return nil, communityErr(err) + } + requests := make([]tg.CommunityPeerRequest, 0, len(page.Requests)) + for _, item := range page.Requests { + requests = append(requests, tg.CommunityPeerRequest{Visible: item.Visibility == domain.CommunityPeerVisible, Peer: tgPeer(item.Peer), RequestedBy: item.RequestedBy, Date: item.Date}) + } + out := &tg.CommunitiesPeerLinkRequests{TotalCount: page.TotalCount, Requests: requests, Chats: tgChannels(userID, page.Channels), Users: tgUsers(page.Users)} + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + return out, nil +} + +func (r *Router) onCommunitiesTogglePeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return false, err + } + peer, err := r.communityPeerFromInput(ctx, userID, req.Peer) + if err != nil { + return false, err + } + result, err := r.deps.Communities.DecidePeerLinkRequest(ctx, userID, view.Community.ID, peer, req.Reject, int(r.clock.Now().Unix())) + if err != nil { + return false, communityErr(err) + } + if !req.Reject { + r.emitCommunityLinkService(ctx, userID, result) + r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community) + if result.RequestedBy != userID { + r.refreshAndPushCommunityState(ctx, result.RequestedBy, view.Community.ID, result.Community) + } + } + return true, nil +} + +func (r *Router) onCommunitiesToggleAllPeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return false, err + } + results, err := r.deps.Communities.DecideAllPeerLinkRequests(ctx, userID, view.Community.ID, req.Reject, int(r.clock.Now().Unix())) + if err != nil { + return false, communityErr(err) + } + if !req.Reject { + requesters := map[int64]struct{}{} + for _, result := range results { + r.emitCommunityLinkService(ctx, userID, result) + if result.RequestedBy != 0 && result.RequestedBy != userID { + requesters[result.RequestedBy] = struct{}{} + } + } + if len(results) > 0 { + r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, results[0].Community) + for requester := range requesters { + r.refreshAndPushCommunityState(ctx, requester, view.Community.ID, results[0].Community) + } + } + } + return true, nil +} + +func (r *Router) onCommunitiesToggleParticipantBanned(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return false, err + } + peer, err := r.communityPeerFromInput(ctx, userID, req.Participant) + if err != nil || peer.Type != domain.PeerTypeUser { + return false, peerIDInvalidErr() + } + result, err := r.deps.Communities.ToggleParticipantBanned(ctx, userID, view.Community.ID, peer.ID, req.Unban, int(r.clock.Now().Unix())) + if err != nil { + return false, communityErr(err) + } + for _, removed := range result.RemovedLinks { + r.emitCommunityLinkService(ctx, userID, removed) + } + for _, ban := range result.ChannelBans { + r.invalidateChannelFullBotInfoCacheForChannel(ban.Channel.ID) + r.removeOnlineChannelMemberships(ban.Channel.ID, peer.ID) + r.recordChannelStateForUser(ctx, peer.ID, ban.Channel.ID, false) + cache := newViewerPeerCache(r) + build := func(viewerUserID int64) *tg.Updates { + updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, ban.Channel, ban.Previous, ban.Participant, ban.Date, cache) + if updates != nil && ban.ServiceEvent.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, ban.ServiceEvent); update != nil { + updates.Updates = append([]tg.UpdateClass{update}, updates.Updates...) + } + } + return updates + } + r.pushChannelUpdates(ctx, userID, ban.Channel.ID, ban.Recipients, build) + } + if result.Changed && !req.Unban { + forbidden := domain.CommunityView{Community: view.Community, Forbidden: true, Self: domain.CommunityMember{UserID: peer.ID}} + r.pushUserUpdates(ctx, peer.ID, r.communityUpdates(forbidden)) + } + return true, nil +} + +func (r *Router) onCommunitiesGetParticipantJoinedChats(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (*tg.CommunitiesParticipantJoinedChats, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + view, err := r.communityFromInput(ctx, userID, req.Community) + if err != nil { + return nil, err + } + peer, err := r.communityPeerFromInput(ctx, userID, req.Participant) + if err != nil || peer.Type != domain.PeerTypeUser { + return nil, peerIDInvalidErr() + } + joined, err := r.deps.Communities.ParticipantJoinedChats(ctx, userID, view.Community.ID, peer.ID) + if err != nil { + return nil, communityErr(err) + } + return &tg.CommunitiesParticipantJoinedChats{CreatorChatIDs: joined.CreatorChatIDs, JoinedChatIDs: joined.JoinedChatIDs, Chats: tgChannels(userID, joined.Channels), Users: tgUsers(joined.Users)}, nil +} diff --git a/internal/rpc/communities_register.go b/internal/rpc/communities_register.go new file mode 100644 index 00000000..303e60ab --- /dev/null +++ b/internal/rpc/communities_register.go @@ -0,0 +1,38 @@ +package rpc + +import ( + "context" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" +) + +func (r *Router) registerCommunities(d *tlprofile.Dispatcher) { + registerRPC[*tg.CommunitiesCreateRequest](d, tlprofile.SemanticMethodCommunitiesCreate, func(ctx context.Context, req *tg.CommunitiesCreateRequest) (any, error) { + return r.onCommunitiesCreate(ctx, req) + }) + registerRPC[*tg.CommunitiesTogglePeerLinkRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLink, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (any, error) { + return r.onCommunitiesTogglePeerLink(ctx, req) + }) + registerRPC[*tg.CommunitiesGetJoinedCommunitiesRequest](d, tlprofile.SemanticMethodCommunitiesGetJoinedCommunities, func(ctx context.Context, req *tg.CommunitiesGetJoinedCommunitiesRequest) (any, error) { + return r.onCommunitiesGetJoined(ctx) + }) + registerRPC[*tg.CommunitiesToggleCommunityCollapsedInDialogsRequest](d, tlprofile.SemanticMethodCommunitiesToggleCommunityCollapsedInDialogs, func(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (any, error) { + return r.onCommunitiesToggleCollapsed(ctx, req) + }) + registerRPC[*tg.CommunitiesGetPeerLinkRequestsRequest](d, tlprofile.SemanticMethodCommunitiesGetPeerLinkRequests, func(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (any, error) { + return r.onCommunitiesGetPeerLinkRequests(ctx, req) + }) + registerRPC[*tg.CommunitiesTogglePeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (any, error) { + return r.onCommunitiesTogglePeerLinkRequestApproval(ctx, req) + }) + registerRPC[*tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesToggleAllPeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (any, error) { + return r.onCommunitiesToggleAllPeerLinkRequestApproval(ctx, req) + }) + registerRPC[*tg.CommunitiesToggleParticipantBannedRequest](d, tlprofile.SemanticMethodCommunitiesToggleParticipantBanned, func(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (any, error) { + return r.onCommunitiesToggleParticipantBanned(ctx, req) + }) + registerRPC[*tg.CommunitiesGetParticipantJoinedChatsRequest](d, tlprofile.SemanticMethodCommunitiesGetParticipantJoinedChats, func(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (any, error) { + return r.onCommunitiesGetParticipantJoinedChats(ctx, req) + }) +} diff --git a/internal/rpc/communities_rpc_test.go b/internal/rpc/communities_rpc_test.go new file mode 100644 index 00000000..f0bfaa60 --- /dev/null +++ b/internal/rpc/communities_rpc_test.go @@ -0,0 +1,334 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appcommunities "telesrv/internal/app/communities" + appdialogs "telesrv/internal/app/dialogs" + appstories "telesrv/internal/app/stories" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func communityRPCChannel(t *testing.T, service *appchannels.Service, creator domain.User, title string, members ...domain.User) domain.Channel { + t.Helper() + memberIDs := make([]int64, 0, len(members)) + for _, member := range members { + memberIDs = append(memberIDs, member.ID) + } + created, err := service.CreateChannel(context.Background(), creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: title, + Megagroup: true, + MemberUserIDs: memberIDs, + Date: 1_800_100_000, + }) + if err != nil { + t.Fatalf("create channel %q: %v", title, err) + } + return created.Channel +} + +func TestCommunityDialogsSharePinnedLimit(t *testing.T) { + ctx := WithLayer(context.Background(), communitiesLayer) + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 711, Phone: "15552000011", FirstName: "Pin Owner"}) + if err != nil { + t.Fatal(err) + } + channels := memory.NewChannelStore() + channelService := appchannels.NewService(channels) + communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil)) + r := New(Config{}, Deps{ + Users: appusers.NewService(users), Channels: channelService, + Communities: communityService, Dialogs: appdialogs.NewService(memory.NewDialogStore(), channels), + }, zaptest.NewLogger(t), clock.System) + + inputs := make([]*tg.InputChannel, 0, domain.MaxPinnedDialogsMainFolder) + for i := 0; i < domain.MaxPinnedDialogsMainFolder; i++ { + channel := communityRPCChannel(t, channelService, owner, "Pinned Community Channel") + view, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{ + Title: "Pinned Community", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Visibility: domain.CommunityPeerVisible, Date: 1_800_110_000 + i, + }) + if err != nil { + t.Fatalf("create community %d: %v", i, err) + } + if _, _, err := communityService.SetCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil { + t.Fatalf("collapse community %d: %v", i, err) + } + inputs = append(inputs, &tg.InputChannel{ChannelID: view.Community.ID, AccessHash: view.Community.AccessHash}) + } + for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ { + input := inputs[i] + toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: input}} + toggle.SetPinned(true) + ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), toggle) + if err != nil || !ok { + t.Fatalf("pin community %d = %v, %v", i, ok, err) + } + } + joined, err := communityService.ListJoined(ctx, owner.ID) + if err != nil || len(joined) != domain.MaxPinnedDialogsMainFolder { + t.Fatalf("joined Communities before ordinary pin = %+v, %v", joined, err) + } + for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ { + if !joined[i].State.Pinned || !joined[i].State.Collapsed { + t.Fatalf("joined Community %d state before ordinary pin = %+v", i, joined[i].State) + } + } + ordinary := communityRPCChannel(t, channelService, owner, "Ordinary Pinned Channel") + ordinaryToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ + ChannelID: ordinary.ID, AccessHash: ordinary.AccessHash, + }}} + ordinaryToggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), ordinaryToggle); err != nil || !ok { + t.Fatalf("pin ordinary dialog at shared limit = %v, %v", ok, err) + } + pinned, err := r.pinnedDialogsList(ctx, owner.ID, domain.DialogMainFolderID) + if err != nil { + t.Fatal(err) + } + order := combinedPinnedDialogPeers(pinned) + if len(order) != domain.MaxPinnedDialogsMainFolder || order[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: ordinary.ID}) { + t.Fatalf("combined pinned order = %+v (dialogs=%+v communities=%+v count=%d), want ordinary dialog promoted above Communities", order, pinned.Dialogs, pinned.Communities, pinned.Count) + } + legacyPinned, err := r.pinnedDialogsList(WithLayer(ctx, 227), owner.ID, domain.DialogMainFolderID) + if err != nil { + t.Fatal(err) + } + if len(legacyPinned.Communities) != 0 || len(legacyPinned.Dialogs) != 1 || legacyPinned.Count != 1 { + t.Fatalf("Layer 227 pinned dialogs = %+v, want only the ordinary pinned dialog", legacyPinned) + } + legacyOrdinary := communityRPCChannel(t, channelService, owner, "Legacy Ordinary Pinned Channel") + legacyToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ + ChannelID: legacyOrdinary.ID, AccessHash: legacyOrdinary.AccessHash, + }}} + legacyToggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(WithLayer(WithUserID(ctx, owner.ID), 227), legacyToggle); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") { + t.Fatalf("Layer 227 pin beyond shared account limit = %v, %v", ok, err) + } + overLimit := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: inputs[len(inputs)-1]}} + overLimit.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), overLimit); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") { + t.Fatalf("pin over shared limit = %v, %v", ok, err) + } + if _, err := r.onMessagesReorderPinnedDialogs(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedDialogsRequest{ + FolderID: domain.DialogArchiveFolderID, + Order: []tg.InputDialogPeerClass{&tg.InputDialogPeerCommunity{Community: inputs[0]}}, + }); err == nil || !tgerr.Is(err, "FOLDER_ID_INVALID") { + t.Fatalf("archive Community reorder error = %v", err) + } +} + +func TestCommunitiesRPCLayer228Lifecycle(t *testing.T) { + ctx := WithLayer(context.Background(), communitiesLayer) + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 701, Phone: "15552000001", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 702, Phone: "15552000002", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + initial := communityRPCChannel(t, channelService, owner, "Initial", member) + communityService := appcommunities.NewService(memory.NewCommunityStore(userStore, channelStore, nil, nil)) + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, ID: 1, + Date: 1_800_100_001, ExpireDate: 1_900_100_001, Public: true, + }}); err != nil { + t.Fatalf("seed owner story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Communities: communityService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_800_100_100, 0)}) + + createdResult, err := r.onCommunitiesCreate(WithUserID(ctx, owner.ID), &tg.CommunitiesCreateRequest{ + Hidden: true, + Title: "Official Community", + About: "Layer 228", + Peer: &tg.InputPeerChannel{ChannelID: initial.ID, AccessHash: initial.AccessHash}, + }) + if err != nil { + t.Fatalf("communities.create: %v", err) + } + created, ok := createdResult.(*tg.Updates) + if !ok || len(created.Chats) != 1 { + t.Fatalf("create result = %#v, want Updates with Community", createdResult) + } + community, ok := created.Chats[0].(*tg.Community) + if !ok || community.Title != "Official Community" || !community.Creator { + t.Fatalf("create chat = %#v", created.Chats[0]) + } + inputCommunity := &tg.InputChannel{ChannelID: community.ID, AccessHash: community.AccessHash} + + joinedResult, err := r.onCommunitiesGetJoined(WithUserID(ctx, member.ID)) + if err != nil { + t.Fatalf("communities.getJoinedCommunities: %v", err) + } + joined := joinedResult.(*tg.MessagesChats) + if len(joined.Chats) != 1 || joined.Chats[0].(*tg.Community).ID != community.ID { + t.Fatalf("joined communities = %+v", joined.Chats) + } + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputCommunity) + if err != nil { + t.Fatalf("channels.getFullChannel community: %v", err) + } + communityFull, ok := full.FullChat.(*tg.CommunityFull) + if !ok || communityFull.About != "Layer 228" || len(communityFull.LinkedPeers) != 1 || len(full.Chats) != 2 { + t.Fatalf("community full = %#v chats=%+v", full.FullChat, full.Chats) + } + + collapsedResult, err := r.onCommunitiesToggleCollapsed(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleCommunityCollapsedInDialogsRequest{ + Collapsed: true, + Community: inputCommunity, + }) + if err != nil { + t.Fatalf("toggle collapsed: %v", err) + } + collapsed := collapsedResult.(*tg.Updates) + if len(collapsed.Chats) == 0 || !collapsed.Chats[0].(*tg.Community).CollapsedInDialogs { + t.Fatalf("collapsed updates = %+v", collapsed.Chats) + } + legacyList, err := r.withCommunityDialogList(WithLayer(ctx, 227), owner.ID, domain.DialogFilter{}, domain.DialogList{Count: 7}) + if err != nil || len(legacyList.Communities) != 0 || legacyList.Count != 7 { + t.Fatalf("Layer 227 community dialog projection = %+v err=%v, want unchanged list", legacyList, err) + } + list, err := r.withCommunityDialogList(ctx, owner.ID, domain.DialogFilter{}, domain.DialogList{}) + if err != nil || len(list.Communities) != 1 || list.Count != 1 { + t.Fatalf("community dialog list = %+v err=%v", list, err) + } + dialogs := tgMessagesDialogs(owner.ID, list).(*tg.MessagesDialogs) + if len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want dialogCommunity", dialogs.Dialogs) + } + if dialog, ok := dialogs.Dialogs[0].(*tg.DialogCommunity); !ok || dialog.CommunityID != community.ID { + t.Fatalf("dialog = %#v, want community %d", dialogs.Dialogs[0], community.ID) + } + + ownedOne := communityRPCChannel(t, channelService, member, "Owned One") + ownedTwo := communityRPCChannel(t, channelService, member, "Owned Two") + requestLink := func(channel domain.Channel) { + t.Helper() + ok, err := r.onCommunitiesTogglePeerLink(WithUserID(ctx, member.ID), &tg.CommunitiesTogglePeerLinkRequest{ + Visible: true, + Community: inputCommunity, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err == nil || ok || !tgerr.Is(err, "COMMUNITY_REQUEST_CREATED") { + t.Fatalf("request link %d = %v, %v", channel.ID, ok, err) + } + } + requestLink(ownedOne) + requestLink(ownedTwo) + + requests, err := r.onCommunitiesGetPeerLinkRequests(WithUserID(ctx, owner.ID), &tg.CommunitiesGetPeerLinkRequestsRequest{ + Community: inputCommunity, + Limit: 20, + }) + if err != nil || requests.TotalCount != 2 || len(requests.Requests) != 2 { + t.Fatalf("peer link requests = %+v err=%v", requests, err) + } + // The Community owner is deliberately not a member of Owned One. Approval + // must use the request's validated ownership rather than ordinary channel + // membership access. + approved, err := r.onCommunitiesTogglePeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesTogglePeerLinkRequestApprovalRequest{ + Community: inputCommunity, + Peer: &tg.InputPeerChannel{ChannelID: ownedOne.ID, AccessHash: ownedOne.AccessHash}, + }) + if err != nil || !approved { + t.Fatalf("approve peer link = %v, %v", approved, err) + } + approved, err = r.onCommunitiesToggleAllPeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest{Community: inputCommunity}) + if err != nil || !approved { + t.Fatalf("approve all peer links = %v, %v", approved, err) + } + + joinedChats, err := r.onCommunitiesGetParticipantJoinedChats(WithUserID(ctx, owner.ID), &tg.CommunitiesGetParticipantJoinedChatsRequest{ + Community: inputCommunity, + Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash}, + }) + if err != nil || len(joinedChats.JoinedChatIDs) != 3 || len(joinedChats.CreatorChatIDs) != 2 { + t.Fatalf("participant joined chats = %+v err=%v", joinedChats, err) + } + + participantsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: inputCommunity, + Filter: &tg.ChannelParticipantsSearch{Q: "memBER"}, + Limit: 20, + }) + if err != nil { + t.Fatalf("community participants search: %v", err) + } + participants := participantsResult.(*tg.ChannelsChannelParticipants) + if participants.Count != 1 || len(participants.Participants) != 1 { + t.Fatalf("community participants = %+v", participants) + } + adminsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: inputCommunity, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 100, + }) + if err != nil { + t.Fatalf("ordinary member Community admins: %v", err) + } + admins := adminsResult.(*tg.ChannelsChannelParticipants) + if admins.Count != 1 || len(admins.Participants) != 1 { + t.Fatalf("ordinary member Community admins = %+v, want creator", admins) + } + if _, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: inputCommunity, + Filter: &tg.ChannelParticipantsBanned{Q: ""}, + Limit: 100, + }); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { + t.Fatalf("ordinary member Community banned list err = %v, want CHAT_ADMIN_REQUIRED", err) + } + + recent, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{ + &tg.InputPeerSelf{}, + &tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash}, + &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("stories.getPeerMaxIDs with Community slot: %v", err) + } + if len(recent) != 3 { + t.Fatalf("stories.getPeerMaxIDs slots = %d, want 3", len(recent)) + } + for _, index := range []int{0, 2} { + if maxID, ok := recent[index].GetMaxID(); !ok || maxID != 1 { + t.Fatalf("stories.getPeerMaxIDs[%d] max_id = %d ok=%v, want 1 true", index, maxID, ok) + } + } + if maxID, ok := recent[1].GetMaxID(); ok || maxID != 0 || recent[1].Live { + t.Fatalf("stories.getPeerMaxIDs Community slot = %+v, want empty recentStory", recent[1]) + } + if _, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{ + &tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash + 1}, + }); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") { + t.Fatalf("stories.getPeerMaxIDs Community wrong hash err = %v, want CHANNEL_PRIVATE", err) + } + + banned, err := r.onCommunitiesToggleParticipantBanned(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleParticipantBannedRequest{ + Community: inputCommunity, + Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash}, + }) + if err != nil || !banned { + t.Fatalf("toggle participant banned = %v, %v", banned, err) + } + joinedResult, err = r.onCommunitiesGetJoined(WithUserID(ctx, member.ID)) + if err != nil || len(joinedResult.(*tg.MessagesChats).Chats) != 0 { + t.Fatalf("banned member joined communities = %#v err=%v", joinedResult, err) + } +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index bbc5e649..44ae66fe 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -82,7 +82,7 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla return nil } peer := &tg.PeerChannel{ChannelID: m.ChannelID} - outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel + outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && (m.SavedPeer.ID != 0 || m.From.Type != domain.PeerTypeChannel) from := tg.PeerClass(nil) if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { from = tgPeer(*m.SendAs) @@ -139,6 +139,12 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla // 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。 msg.SetSavedPeerID(tgPeer(m.SavedPeer)) } + if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok { + msg.SetSuggestedPost(suggested) + } + if m.PaidMessageStars > 0 { + msg.SetPaidMessageStars(m.PaidMessageStars) + } if m.Pinned { msg.SetPinned(true) } @@ -284,11 +290,19 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction } case domain.ChannelActionStarGift: return tgMessageActionStarGift(action.StarGift) + case domain.ChannelActionStarGiftUnique: + return tgMessageActionStarGiftUnique(action.StarGiftUnique) case domain.ChannelActionSetChatWallpaper: if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil { return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper} } return nil + case domain.ChannelActionChangeCommunity: + out := &tg.MessageActionChangeCommunity{} + if action.CommunityID != 0 { + out.SetCommunityID(action.CommunityID) + } + return out default: return nil } @@ -423,6 +437,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed { out.SetLinkedMonoforumID(ch.LinkedMonoforumID) } + if ch.LinkedCommunityID != 0 { + out.SetLinkedCommunityID(ch.LinkedCommunityID) + } if ch.Username != "" { out.SetUsername(ch.Username) out.SetUsernames(tgUsernames(ch.Username)) @@ -792,21 +809,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { return tg.ChatAdminRights{ - ChangeInfo: rights.ChangeInfo, - PostMessages: rights.PostMessages, - EditMessages: rights.EditMessages, - DeleteMessages: rights.DeleteMessages, - PostStories: rights.PostStories, - EditStories: rights.EditStories, - DeleteStories: rights.DeleteStories, - BanUsers: rights.BanUsers, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - AddAdmins: rights.AddAdmins, - Anonymous: rights.Anonymous, - ManageCall: rights.ManageCall, - Other: true, - ManageRanks: rights.ManageRanks, + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + PostStories: rights.PostStories, + EditStories: rights.EditStories, + DeleteStories: rights.DeleteStories, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + Other: true, + ManageTopics: rights.ManageTopics, + ManageRanks: rights.ManageRanks, + ManageLinkedPeers: rights.ManageLinkedPeers, // manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum, // 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。 ManageDirectMessages: rights.ManageDirectMessages, @@ -832,36 +851,40 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh AddAdmins: rights.AddAdmins, Anonymous: rights.Anonymous, ManageCall: rights.ManageCall, + ManageChat: rights.Other, + ManageTopics: rights.ManageTopics, ManageRanks: rights.ManageRanks, + ManageLinkedPeers: rights.ManageLinkedPeers, ManageDirectMessages: rights.ManageDirectMessages, } } func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights { return tg.ChatBannedRights{ - ViewMessages: rights.ViewMessages, - SendMessages: rights.SendMessages, - SendMedia: rights.SendMedia, - SendStickers: rights.SendStickers, - SendGifs: rights.SendGifs, - SendGames: rights.SendGames, - SendInline: rights.SendInline, - EmbedLinks: rights.EmbedLinks, - SendPolls: rights.SendPolls, - ChangeInfo: rights.ChangeInfo, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - ManageTopics: rights.ManageTopics, - SendPhotos: rights.SendPhotos, - SendVideos: rights.SendVideos, - SendRoundvideos: rights.SendRoundvideos, - SendAudios: rights.SendAudios, - SendVoices: rights.SendVoices, - SendDocs: rights.SendDocs, - SendPlain: rights.SendPlain, - EditRank: rights.EditRank, - SendReactions: rights.SendReactions, - UntilDate: rights.UntilDate, + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + ManageTopics: rights.ManageTopics, + SendPhotos: rights.SendPhotos, + SendVideos: rights.SendVideos, + SendRoundvideos: rights.SendRoundvideos, + SendAudios: rights.SendAudios, + SendVoices: rights.SendVoices, + SendDocs: rights.SendDocs, + SendPlain: rights.SendPlain, + EditRank: rights.EditRank, + SendReactions: rights.SendReactions, + ManageLinkedPeers: rights.ManageLinkedPeers, + UntilDate: rights.UntilDate, } } @@ -875,29 +898,30 @@ func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedR func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights { return domain.ChannelBannedRights{ - ViewMessages: rights.ViewMessages, - SendMessages: rights.SendMessages, - SendMedia: rights.SendMedia, - SendStickers: rights.SendStickers, - SendGifs: rights.SendGifs, - SendGames: rights.SendGames, - SendInline: rights.SendInline, - EmbedLinks: rights.EmbedLinks, - SendPolls: rights.SendPolls, - ChangeInfo: rights.ChangeInfo, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - ManageTopics: rights.ManageTopics, - SendPhotos: rights.SendPhotos, - SendVideos: rights.SendVideos, - SendRoundvideos: rights.SendRoundvideos, - SendAudios: rights.SendAudios, - SendVoices: rights.SendVoices, - SendDocs: rights.SendDocs, - SendPlain: rights.SendPlain, - EditRank: rights.EditRank, - SendReactions: rights.SendReactions, - UntilDate: rights.UntilDate, + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + ManageTopics: rights.ManageTopics, + SendPhotos: rights.SendPhotos, + SendVideos: rights.SendVideos, + SendRoundvideos: rights.SendRoundvideos, + SendAudios: rights.SendAudios, + SendVoices: rights.SendVoices, + SendDocs: rights.SendDocs, + SendPlain: rights.SendPlain, + EditRank: rights.EditRank, + SendReactions: rights.SendReactions, + ManageLinkedPeers: rights.ManageLinkedPeers, + UntilDate: rights.UntilDate, } } diff --git a/internal/rpc/convert_communities.go b/internal/rpc/convert_communities.go new file mode 100644 index 00000000..95853df6 --- /dev/null +++ b/internal/rpc/convert_communities.go @@ -0,0 +1,104 @@ +package rpc + +import ( + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +func tgCommunityPhoto(c domain.Community) tg.ChatPhotoClass { + if c.PhotoID == 0 { + return &tg.ChatPhotoEmpty{} + } + out := &tg.ChatPhoto{PhotoID: c.PhotoID, DCID: c.PhotoDCID} + if len(c.PhotoStripped) > 0 { + out.SetStrippedThumb(c.PhotoStripped) + } + return out +} + +func tgCommunityFullPhoto(c domain.Community) tg.PhotoClass { + if c.PhotoID == 0 { + return &tg.PhotoEmpty{} + } + sizes := syntheticAvatarSizes() + if len(c.PhotoStripped) > 0 { + sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: c.PhotoStripped}}, sizes...) + } + return &tg.Photo{ID: c.PhotoID, DCID: c.PhotoDCID, Sizes: sizes} +} + +func tgCommunityChat(view domain.CommunityView) tg.ChatClass { + c := view.Community + if c.Deleted || view.Forbidden { + return &tg.CommunityForbidden{ID: c.ID, AccessHash: c.AccessHash, Title: c.Title} + } + out := &tg.Community{Creator: view.Creator(), CollapsedInDialogs: view.State.Collapsed, ID: c.ID, Title: c.Title, Photo: tgCommunityPhoto(c), Date: c.Date} + out.SetAccessHash(c.AccessHash) + if view.Self.Role == domain.CommunityRoleCreator { + out.SetAdminRights(tgChatAdminRights(domain.CreatorChannelAdminRights())) + } else if view.Self.Role == domain.CommunityRoleAdmin { + out.SetAdminRights(tgChatAdminRights(view.Self.AdminRights)) + } + out.SetDefaultBannedRights(tgDefaultChatBannedRights(c.DefaultBannedRights)) + return out +} + +func tgCommunityChats(views []domain.CommunityView) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(views)) + for _, v := range views { + out = append(out, tgCommunityChat(v)) + } + return out +} + +func tgCommunityPeer(link domain.CommunityPeerLink) tg.CommunityPeer { + out := tg.CommunityPeer{CanViewHistory: link.CanViewHistory, Peer: tgPeer(link.Peer)} + out.SetVisible(link.Visible()) + return out +} + +func tgCommunityFull(view domain.CommunityView) *tg.CommunityFull { + links := make([]tg.CommunityPeer, 0, len(view.Links)) + for _, l := range view.Links { + links = append(links, tgCommunityPeer(l)) + } + out := &tg.CommunityFull{ID: view.Community.ID, About: view.Community.About, ChatPhoto: tgCommunityFullPhoto(view.Community), LinkedPeers: links} + if view.AdminsCount > 0 { + out.SetAdminsCount(view.AdminsCount) + } + if view.KickedCount > 0 { + out.SetKickedCount(view.KickedCount) + } + if view.PendingRequests > 0 { + out.SetPeerLinkRequestsPending(view.PendingRequests) + } + return out +} + +func tgCommunityHydratedChats(viewerUserID int64, view domain.CommunityView) []tg.ChatClass { + out := []tg.ChatClass{tgCommunityChat(view)} + for _, ch := range view.Channels { + out = appendUniqueTGChats(out, tgChannelChatMin(viewerUserID, ch)) + } + return out +} + +func tgCommunityMember(viewerUserID int64, m domain.CommunityMember) tg.ChannelParticipantClass { + cm := domain.ChannelMember{ChannelID: m.CommunityID, UserID: m.UserID, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleMember, AdminRights: m.AdminRights, Rank: m.Rank, JoinedAt: m.Date} + if m.Status == domain.CommunityMemberKicked { + cm.Status = domain.ChannelMemberKicked + cm.BannedRights = domain.ChannelBannedRights{ViewMessages: true} + } + switch m.Role { + case domain.CommunityRoleCreator: + cm.Role = domain.ChannelRoleCreator + case domain.CommunityRoleAdmin: + cm.Role = domain.ChannelRoleAdmin + } + return tgChannelParticipant(viewerUserID, cm) +} + +func tgCommunityDialog(view domain.CommunityView, notify *domain.PeerNotifySettings) *tg.DialogCommunity { + return &tg.DialogCommunity{Pinned: view.State.Pinned, CommunityID: view.Community.ID, NotifySettings: *tgPeerNotifySettings(notify)} +} diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index ca72a92a..a77b5105 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -1,10 +1,56 @@ package rpc import ( + "sort" + "github.com/iamxvbaba/td/tg" "telesrv/internal/domain" ) +type dialogProjection struct { + dialog tg.DialogClass + pinned bool + pinnedOrder int + sequence int +} + +func projectedDialogs(list domain.DialogList) []tg.DialogClass { + items := make([]dialogProjection, 0, len(list.Dialogs)+len(list.Communities)) + for _, d := range list.Dialogs { + if dialog := tgDialog(d); dialog != nil { + items = append(items, dialogProjection{dialog: dialog, pinned: d.Pinned, pinnedOrder: d.PinnedOrder, sequence: len(items)}) + } + } + for _, community := range list.Communities { + items = append(items, dialogProjection{ + dialog: tgCommunityDialog(community, community.State.NotifySettings), pinned: community.State.Pinned, + pinnedOrder: community.State.PinnedOrder, sequence: len(items), + }) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].pinned != items[j].pinned { + return items[i].pinned + } + if items[i].pinned && items[i].pinnedOrder != items[j].pinnedOrder { + return items[i].pinnedOrder > items[j].pinnedOrder + } + return items[i].sequence < items[j].sequence + }) + out := make([]tg.DialogClass, 0, len(items)) + for _, item := range items { + out = append(out, item.dialog) + } + return out +} + +func appendCommunityDialogObjects(viewerUserID int64, list domain.DialogList, chats []tg.ChatClass, users []tg.UserClass) ([]tg.ChatClass, []tg.UserClass) { + for _, community := range list.Communities { + chats = appendUniqueTGChats(chats, tgCommunityHydratedChats(viewerUserID, community)...) + users = appendUniqueTGUsers(users, tgUsersForViewer(viewerUserID, community.Users)...) + } + return chats, users +} + func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass { dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)+1) // dialogFolder 条目排在最前:TDesktop 据它发现 archive folder 并渲染 @@ -12,11 +58,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi if folder := tgDialogFolder(list.ArchiveSummary); folder != nil { dialogs = append(dialogs, folder) } - for _, d := range list.Dialogs { - if dialog := tgDialog(d); dialog != nil { - dialogs = append(dialogs, dialog) - } - } + dialogs = append(dialogs, projectedDialogs(list)...) messages := make([]tg.MessageClass, 0, len(list.Messages)) for _, msg := range list.Messages { if item := tgMessage(msg); item != nil { @@ -30,6 +72,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi } users := tgUsersForViewer(viewerUserID, list.Users) chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs) + chats, users = appendCommunityDialogObjects(viewerUserID, list, chats, users) if list.Count > len(dialogs) { return &tg.MessagesDialogsSlice{ Count: list.Count, @@ -61,11 +104,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS if folder := tgDialogFolder(list.ArchiveSummary); folder != nil { out.Dialogs = append(out.Dialogs, folder) } - for _, d := range list.Dialogs { - if dialog := tgDialog(d); dialog != nil { - out.Dialogs = append(out.Dialogs, dialog) - } - } + out.Dialogs = append(out.Dialogs, projectedDialogs(list)...) for _, msg := range list.Messages { if item := tgMessage(msg); item != nil { out.Messages = append(out.Messages, item) @@ -84,6 +123,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS } } out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...) + out.Chats, out.Users = appendCommunityDialogObjects(viewerUserID, list, out.Chats, out.Users) return out } @@ -165,6 +205,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { if rich := mustTGRichMessage(d.RichMessage); rich != nil { out.SetRichMessage(*rich) } + if suggested, ok := tgSuggestedPost(d.SuggestedPost); ok { + out.SetSuggestedPost(suggested) + } return out } @@ -201,6 +244,9 @@ func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass { } func tgDialogPeer(p domain.Peer) tg.DialogPeerClass { + if p.Type == domain.PeerTypeCommunity && p.ID > 0 { + return &tg.DialogPeerCommunity{CommunityID: p.ID} + } peer := tgPeer(p) if peer == nil { return nil diff --git a/internal/rpc/convert_markup.go b/internal/rpc/convert_markup.go index 127df4fd..ef248d2e 100644 --- a/internal/rpc/convert_markup.go +++ b/internal/rpc/convert_markup.go @@ -1,6 +1,7 @@ package rpc import ( + "context" "errors" "github.com/iamxvbaba/td/tg" @@ -9,9 +10,30 @@ import ( "telesrv/internal/domain" ) +// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control +// a chat input field and are not supported in broadcast channels. Inline keyboards remain +// valid in both megagroups and broadcasts. +func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error { + if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel { + return nil + } + if r == nil || r.deps.Channels == nil { + return channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return channelInvalidErr(err) + } + if view.Channel.Broadcast && !view.Channel.Megagroup { + return replyMarkupInvalidErr() + } + return nil +} + // P3 reply_markup 错误码(对齐官方)。 func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") } func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") } +func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") } func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") } // replyMarkupErr 把 domain 校验错误映射为客户端错误码。 @@ -21,18 +43,21 @@ func replyMarkupErr(err error) error { return buttonDataInvalidErr() case errors.Is(err, domain.ErrButtonURLInvalid): return buttonURLInvalidErr() - case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid): + case errors.Is(err, domain.ErrButtonTypeInvalid): + return buttonTypeInvalidErr() + case errors.Is(err, domain.ErrButtonInvalid): return buttonInvalidErr() default: return replyMarkupInvalidErr() } } -// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义: +// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markup(inline +// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。 +// 语义: // - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil,不报错——对齐 // 官方「普通用户 markup 无效」,I1)。 -// - 仅 ReplyInlineMarkup 被处理;reply keyboard 家族(自定义键盘/隐藏/强制回复) -// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4)。 +// - 仅 ReplyInlineMarkup 被处理;bot 的 reply keyboard 家族在这些上下文中显式拒绝。 // - inline 行内按钮仅 callback / url;其它按钮类型(webview/game/url_auth/ // request_* 等)→ ErrButtonTypeInvalid(拒绝整条发送,绝不半实现下发)。 // - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。 @@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (* } inline, ok := markup.(*tg.ReplyInlineMarkup) if !ok { - // reply keyboard / hide / force-reply:P3 不支持,丢弃。 - return nil, nil + return nil, domain.ErrButtonTypeInvalid } parsed, err := domainInlineMarkup(inline) if err != nil { @@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (* return parsed, nil } +// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。 +// 非 bot 携带 markup 仍按官方权限边界静默丢弃;bot 的未知/未实现按钮则拒绝整条消息, +// 避免客户端看到一个被服务端悄悄改形的键盘。 +func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) { + if markup == nil || !senderIsBot { + return nil, nil + } + switch v := markup.(type) { + case *tg.ReplyInlineMarkup: + return domainReplyMarkupForSender(v, true) + case *tg.ReplyKeyboardMarkup: + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)), + Resize: v.Resize, + SingleUse: v.SingleUse, + Selective: v.Selective, + Persistent: v.Persistent, + Placeholder: v.Placeholder, + } + for _, row := range v.Rows { + domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) + for _, button := range row.Buttons { + parsed, err := domainReplyKeyboardButton(button) + if err != nil { + return nil, err + } + domainRow = append(domainRow, parsed) + } + out.Keyboard = append(out.Keyboard, domainRow) + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + case *tg.ReplyKeyboardHide: + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective} + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + case *tg.ReplyKeyboardForceReply: + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupForceReply, + SingleUse: v.SingleUse, + Selective: v.Selective, + Placeholder: v.Placeholder, + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + default: + return nil, domain.ErrButtonTypeInvalid + } +} + +func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) { + style, icon, err := domainMarkupButtonStyle(button) + if err != nil { + return domain.MarkupButton{}, err + } + base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon} + switch b := button.(type) { + case *tg.KeyboardButton: + base.Type, base.Text = domain.MarkupButtonText, b.Text + case *tg.KeyboardButtonRequestPhone: + base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text + case *tg.KeyboardButtonRequestGeoLocation: + base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text + case *tg.KeyboardButtonRequestPoll: + base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text + if quiz, ok := b.GetQuiz(); ok { + if quiz { + base.PollType = "quiz" + } else { + base.PollType = "regular" + } + } + case *tg.KeyboardButtonRequestPeer: + base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text + base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity + base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType) + case *tg.KeyboardButtonSimpleWebView: + base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL + default: + return domain.MarkupButton{}, domain.ErrButtonTypeInvalid + } + return base, nil +} + func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) { - out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} for _, row := range inline.Rows { domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) for _, btn := range row.Buttons { @@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku } func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) { + style, icon, err := domainMarkupButtonStyle(btn) + if err != nil { + return domain.MarkupButton{}, err + } switch b := btn.(type) { case *tg.KeyboardButtonCallback: return domain.MarkupButton{ - Type: domain.MarkupButtonCallback, - Text: b.Text, - Data: append([]byte(nil), b.Data...), - RequiresPassword: b.RequiresPassword, + Type: domain.MarkupButtonCallback, + Text: b.Text, + Style: style, + IconCustomEmojiID: icon, + Data: append([]byte(nil), b.Data...), + RequiresPassword: b.RequiresPassword, }, nil case *tg.KeyboardButtonURL: return domain.MarkupButton{ - Type: domain.MarkupButtonURL, - Text: b.Text, - URL: b.URL, + Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL, + Style: style, IconCustomEmojiID: icon, }, nil + case *tg.KeyboardButtonWebView: + return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil + case *tg.KeyboardButtonSwitchInline: + peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes) + if err != nil { + return domain.MarkupButton{}, domain.ErrButtonInvalid + } + return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil + case *tg.KeyboardButtonCopy: + return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil default: // webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。 return domain.MarkupButton{}, domain.ErrButtonTypeInvalid } } -// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。 +func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) { + style, ok := btn.GetStyle() + if !ok { + return "", 0, nil + } + colors := 0 + var out domain.MarkupButtonStyle + if style.GetBgPrimary() { + colors++ + out = domain.MarkupButtonStylePrimary + } + if style.GetBgDanger() { + colors++ + out = domain.MarkupButtonStyleDanger + } + if style.GetBgSuccess() { + colors++ + out = domain.MarkupButtonStyleSuccess + } + icon, hasIcon := style.GetIcon() + if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) { + return "", 0, domain.ErrButtonInvalid + } + return out, icon, nil +} + +func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) { + var out tg.KeyboardButtonStyle + switch btn.Style { + case domain.MarkupButtonStylePrimary: + out.SetBgPrimary(true) + case domain.MarkupButtonStyleDanger: + out.SetBgDanger(true) + case domain.MarkupButtonStyleSuccess: + out.SetBgSuccess(true) + } + if btn.IconCustomEmojiID > 0 { + out.SetIcon(btn.IconCustomEmojiID) + } + return out, btn.Style != "" || btn.IconCustomEmojiID > 0 +} + +// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。 func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass { if m.IsZero() { return nil } + switch m.Kind() { + case domain.MessageReplyMarkupKeyboard: + rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard)) + for _, row := range m.Keyboard { + buttons := make([]tg.KeyboardButtonClass, 0, len(row)) + for _, btn := range row { + buttons = append(buttons, tgReplyKeyboardButton(btn)) + } + rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons}) + } + return &tg.ReplyKeyboardMarkup{ + Resize: m.Resize, + SingleUse: m.SingleUse, + Selective: m.Selective, + Persistent: m.Persistent, + Rows: rows, + Placeholder: m.Placeholder, + } + case domain.MessageReplyMarkupHide: + return &tg.ReplyKeyboardHide{Selective: m.Selective} + case domain.MessageReplyMarkupForceReply: + return &tg.ReplyKeyboardForceReply{ + SingleUse: m.SingleUse, + Selective: m.Selective, + Placeholder: m.Placeholder, + } + case domain.MessageReplyMarkupInline: + // Continue below. + default: + return nil + } rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline)) for _, row := range m.Inline { buttons := make([]tg.KeyboardButtonClass, 0, len(row)) @@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass { func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass { switch btn.Type { case domain.MarkupButtonURL: - return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL} + out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonWebView: + out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonSwitchInline: + out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer} + if len(btn.PeerTypes) > 0 { + out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes)) + } + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonCopy: + out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out default: // callback out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data} if btn.RequiresPassword { out.SetRequiresPassword(true) } + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } return out } } + +func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass { + var out tg.KeyboardButtonClass + switch btn.Type { + case domain.MarkupButtonRequestPhone: + out = &tg.KeyboardButtonRequestPhone{Text: btn.Text} + case domain.MarkupButtonRequestLocation: + out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text} + case domain.MarkupButtonRequestPoll: + button := &tg.KeyboardButtonRequestPoll{Text: btn.Text} + if btn.PollType == "quiz" { + button.SetQuiz(true) + } else if btn.PollType == "regular" { + button.SetQuiz(false) + } + out = button + case domain.MarkupButtonRequestPeer: + out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity} + case domain.MarkupButtonSimpleWebView: + out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL} + default: + out = &tg.KeyboardButton{Text: btn.Text} + } + if style, ok := tgMarkupButtonStyle(btn); ok { + if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok { + setter.SetStyle(style) + } + } + return out +} + +func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) { + filter := &domain.BotRequestPeerFilter{} + switch v := peerType.(type) { + case *tg.RequestPeerTypeUser: + if value, ok := v.GetBot(); ok { + filter.UserIsBotSet, filter.UserIsBot = true, value + } + if value, ok := v.GetPremium(); ok { + filter.UserIsPremiumSet, filter.UserIsPremium = true, value + } + if !filter.UserIsBotSet && !filter.UserIsPremiumSet { + return "user", nil + } + return "user", filter + case *tg.RequestPeerTypeChat: + filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant + if value, ok := v.GetHasUsername(); ok { + filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value + } + if value, ok := v.GetForum(); ok { + filter.ChatIsForumSet, filter.ChatIsForum = true, value + } + if rights, ok := v.GetUserAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.UserAdminRights = &mapped + } + if rights, ok := v.GetBotAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.BotAdminRights = &mapped + } + if botRequestPeerFilterZero(filter) { + return "chat", nil + } + return "chat", filter + case *tg.RequestPeerTypeBroadcast: + filter.ChatIsCreated = v.Creator + if value, ok := v.GetHasUsername(); ok { + filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value + } + if rights, ok := v.GetUserAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.UserAdminRights = &mapped + } + if rights, ok := v.GetBotAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.BotAdminRights = &mapped + } + if botRequestPeerFilterZero(filter) { + return "broadcast", nil + } + return "broadcast", filter + default: + return "", nil + } +} + +func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool { + return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet && + !filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil) +} + +func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass { + switch kind { + case "chat": + out := &tg.RequestPeerTypeChat{} + if filter != nil { + out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember + if filter.ChatHasUsernameSet { + out.SetHasUsername(filter.ChatHasUsername) + } + if filter.ChatIsForumSet { + out.SetForum(filter.ChatIsForum) + } + if filter.UserAdminRights != nil { + out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights)) + } + if filter.BotAdminRights != nil { + out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights)) + } + } + return out + case "broadcast": + out := &tg.RequestPeerTypeBroadcast{} + if filter != nil { + out.Creator = filter.ChatIsCreated + if filter.ChatHasUsernameSet { + out.SetHasUsername(filter.ChatHasUsername) + } + if filter.UserAdminRights != nil { + out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights)) + } + if filter.BotAdminRights != nil { + out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights)) + } + } + return out + default: + out := &tg.RequestPeerTypeUser{} + if filter != nil { + if filter.UserIsBotSet { + out.SetBot(filter.UserIsBot) + } + if filter.UserIsPremiumSet { + out.SetPremium(filter.UserIsPremium) + } + } + return out + } +} + +func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights { + return domain.BotRequestAdminRights{ + Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages, + ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins, + ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories, + EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics, + ManageDirectMessages: rights.ManageDirectMessages, + } +} + +func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights { + return tg.ChatAdminRights{ + Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages, + ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers, + ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories, + EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics, + ManageDirectMessages: rights.ManageDirectMessages, + } +} diff --git a/internal/rpc/convert_markup_test.go b/internal/rpc/convert_markup_test.go new file mode 100644 index 00000000..a5336e79 --- /dev/null +++ b/internal/rpc/convert_markup_test.go @@ -0,0 +1,171 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) { + in := &tg.ReplyKeyboardMarkup{ + Resize: true, + SingleUse: true, + Selective: true, + Persistent: true, + Placeholder: "Choose", + Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButton{Text: "Help"}, + func() *tg.KeyboardButton { + button := &tg.KeyboardButton{Text: "Status"} + style := tg.KeyboardButtonStyle{} + style.SetBgPrimary(true) + style.SetIcon(123456) + button.SetStyle(style) + return button + }(), + }}}, + } + got, err := domainOutgoingReplyMarkupForSender(in, true) + if err != nil { + t.Fatalf("domainOutgoingReplyMarkupForSender: %v", err) + } + if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 || + len(got.Keyboard[0]) != 2 || got.Keyboard[0][0].Text != "Help" || !got.Resize || + !got.SingleUse || !got.Selective || !got.Persistent || got.Placeholder != "Choose" { + t.Fatalf("domain markup = %#v", got) + } + if got.Keyboard[0][1].Style != domain.MarkupButtonStylePrimary || got.Keyboard[0][1].IconCustomEmojiID != 123456 { + t.Fatalf("second button decoration = %#v", got.Keyboard[0][1]) + } + wire, ok := tgReplyMarkup(got).(*tg.ReplyKeyboardMarkup) + if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 { + t.Fatalf("wire markup = %#v", wire) + } + if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" { + t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1]) + } else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 { + t.Fatalf("second button style = %#v ok=%v", style, ok) + } + if !wire.Resize || !wire.SingleUse || !wire.Selective || !wire.Persistent || wire.Placeholder != "Choose" { + t.Fatalf("wire flags = %#v", wire) + } +} + +func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) { + button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")} + style := tg.KeyboardButtonStyle{} + style.SetBgDanger(true) + button.SetStyle(style) + got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true) + if err != nil { + t.Fatalf("domainReplyMarkupForSender: %v", err) + } + if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger { + t.Fatalf("domain style = %#v", got.Inline[0][0]) + } + wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback) + if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() { + t.Fatalf("wire style = %#v ok=%v", roundTrip, ok) + } +} + +func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) { + hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true) + if err != nil { + t.Fatalf("hide parse: %v", err) + } + if wire, ok := tgReplyMarkup(hide).(*tg.ReplyKeyboardHide); !ok || !wire.Selective { + t.Fatalf("hide wire = %#v", wire) + } + force, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardForceReply{ + SingleUse: true, Selective: true, Placeholder: "Answer", + }, true) + if err != nil { + t.Fatalf("force parse: %v", err) + } + if wire, ok := tgReplyMarkup(force).(*tg.ReplyKeyboardForceReply); !ok || !wire.SingleUse || !wire.Selective || wire.Placeholder != "Answer" { + t.Fatalf("force wire = %#v", wire) + } +} + +func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) { + markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}}, + }}}, true) + if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 || + markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone { + t.Fatalf("request_phone markup = %#v err=%v", markup, err) + } + wire, ok := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup) + if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 { + t.Fatalf("request_phone wire = %#v", wire) + } + if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok { + t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0]) + } + if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil { + t.Fatal("inline-only edit/result parser must reject reply-keyboard constructors") + } +} + +func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) { + userType := &tg.RequestPeerTypeUser{} + userType.SetBot(false) + userType.SetPremium(true) + chatType := &tg.RequestPeerTypeChat{Creator: true, BotParticipant: true} + chatType.SetHasUsername(false) + chatType.SetForum(true) + chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true}) + in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2}, + &tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1}, + }}}} + markup, err := domainOutgoingReplyMarkupForSender(in, true) + if err != nil { + t.Fatalf("parse request peer filters: %v", err) + } + userFilter := markup.Keyboard[0][0].RequestPeerFilter + chatFilter := markup.Keyboard[0][1].RequestPeerFilter + if userFilter == nil || !userFilter.UserIsBotSet || userFilter.UserIsBot || !userFilter.UserIsPremiumSet || !userFilter.UserIsPremium { + t.Fatalf("user filter = %#v", userFilter) + } + if chatFilter == nil || !chatFilter.ChatIsCreated || !chatFilter.BotIsMember || !chatFilter.ChatHasUsernameSet || + chatFilter.ChatHasUsername || !chatFilter.ChatIsForumSet || !chatFilter.ChatIsForum || + chatFilter.UserAdminRights == nil || !chatFilter.UserAdminRights.DeleteMessages || !chatFilter.UserAdminRights.ManageTopics { + t.Fatalf("chat filter = %#v", chatFilter) + } + wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup) + wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser) + if bot, ok := wireUser.GetBot(); !ok || bot { + t.Fatalf("wire user bot=%v ok=%v", bot, ok) + } + if premium, ok := wireUser.GetPremium(); !ok || !premium { + t.Fatalf("wire user premium=%v ok=%v", premium, ok) + } + wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat) + if !wireChat.Creator || !wireChat.BotParticipant { + t.Fatalf("wire chat = %#v", wireChat) + } + if hasUsername, ok := wireChat.GetHasUsername(); !ok || hasUsername { + t.Fatalf("wire has_username=%v ok=%v", hasUsername, ok) + } + if rights, ok := wireChat.GetUserAdminRights(); !ok || !rights.DeleteMessages || !rights.ManageTopics { + t.Fatalf("wire rights=%#v ok=%v", rights, ok) + } +} + +func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) { + button := &tg.InputKeyboardButtonRequestPeer{ + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3, + } + got, err := domainRequestedButtonFromTG(1001, nil, button) + if err != nil { + t.Fatal(err) + } + if !got.NameRequested || !got.UsernameRequested || !got.PhotoRequested || got.MaxQuantity != 3 { + t.Fatalf("requested button=%#v", got) + } +} diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 784df441..29b36fe8 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -27,6 +27,10 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { if m.TTLSeconds > 0 { out.TTLSeconds = m.TTLSeconds } + if m.LivePhotoVideo != nil { + out.LivePhoto = true + out.SetVideo(tgDocument(*m.LivePhotoVideo)) + } return out case domain.MessageMediaKindDocument: nopremium := m.Nopremium diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 1e119b35..8715cb1b 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass { msg.SetInvertMedia(true) } } - // reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。 + // reply_markup(bot reply/inline keyboard):仅普通 tg.Message 携带(service 消息不带)。 if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { msg.SetReplyMarkup(markup) } @@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { if msg.Out { return &tg.MessageActionRequestedPeerSentMe{ ButtonID: shared.ButtonID, - Peers: tgRequestedPeers(shared.Peers), + Peers: tgRequestedPeers(shared), } } return &tg.MessageActionRequestedPeer{ @@ -221,29 +221,69 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { case domain.MessageServiceActionStarGift: return tgMessageActionStarGift(m.ServiceAction.StarGift) case domain.MessageServiceActionStarGiftUnique: - action := m.ServiceAction.StarGiftUnique + return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique) + case domain.MessageServiceActionStarGiftOffer: + action := m.ServiceAction.StarGiftOffer if action == nil { return &tg.MessageActionEmpty{} } - out := &tg.MessageActionStarGiftUnique{ - Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade, - Gift: tgUniqueStarGift(action.Gift), + return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined, + Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt} + case domain.MessageServiceActionStarGiftOfferDeclined: + action := m.ServiceAction.StarGiftOfferDeclined + if action == nil { + return &tg.MessageActionEmpty{} } - if action.FromUserID != 0 { - out.SetFromID(&tg.PeerUser{UserID: action.FromUserID}) - } - if peer := tgPeer(action.Peer); peer != nil { - out.SetPeer(peer) - } - if action.SavedID != 0 { - out.SetSavedID(action.SavedID) - } - return out + return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired, + Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)} default: return &tg.MessageActionEmpty{} } } +func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass { + if action == nil { + return &tg.MessageActionEmpty{} + } + out := &tg.MessageActionStarGiftUnique{ + Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade, + Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned, + FromOffer: action.FromOffer, Craft: action.Craft, + Gift: tgUniqueStarGift(action.Gift), + } + if action.CanExportAt > 0 { + out.SetCanExportAt(action.CanExportAt) + } + if action.TransferStars > 0 { + out.SetTransferStars(action.TransferStars) + } + if action.ResaleAmount != nil { + out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount)) + } + if action.CanTransferAt > 0 { + out.SetCanTransferAt(action.CanTransferAt) + } + if action.CanResellAt > 0 { + out.SetCanResellAt(action.CanResellAt) + } + if action.DropOriginalDetailsStars > 0 { + out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars) + } + if action.CanCraftAt > 0 { + out.SetCanCraftAt(action.CanCraftAt) + } + if action.FromUserID != 0 { + out.SetFromID(&tg.PeerUser{UserID: action.FromUserID}) + } + if peer := tgPeer(action.Peer); peer != nil { + out.SetPeer(peer) + } + if action.SavedID != 0 { + out.SetSavedID(action.SavedID) + } + return out +} + func tgPeerList(peers []domain.Peer) []tg.PeerClass { out := make([]tg.PeerClass, 0, len(peers)) for _, peer := range peers { @@ -254,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass { return out } -func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass { - out := make([]tg.RequestedPeerClass, 0, len(peers)) - for _, peer := range peers { +func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass { + if action == nil { + return nil + } + details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details)) + for _, detail := range action.Details { + details[detail.Peer] = detail + } + out := make([]tg.RequestedPeerClass, 0, len(action.Peers)) + for _, peer := range action.Peers { + detail := details[peer] switch peer.Type { case domain.PeerTypeUser: - out = append(out, &tg.RequestedPeerUser{UserID: peer.ID}) + item := &tg.RequestedPeerUser{UserID: peer.ID} + if action.NameRequested { + item.SetFirstName(detail.FirstName) + item.SetLastName(detail.LastName) + } + if action.UsernameRequested { + item.SetUsername(detail.Username) + } + if action.PhotoRequested && detail.Photo != nil { + item.SetPhoto(tgPhoto(*detail.Photo)) + } + out = append(out, item) case domain.PeerTypeChannel: - out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID}) + item := &tg.RequestedPeerChannel{ChannelID: peer.ID} + if action.NameRequested { + item.SetTitle(detail.Title) + } + if action.UsernameRequested { + item.SetUsername(detail.Username) + } + if action.PhotoRequested && detail.Photo != nil { + item.SetPhoto(tgPhoto(*detail.Photo)) + } + out = append(out, item) } } return out diff --git a/internal/rpc/convert_updates.go b/internal/rpc/convert_updates.go index 0a65d45d..efdb6bea 100644 --- a/internal/rpc/convert_updates.go +++ b/internal/rpc/convert_updates.go @@ -237,6 +237,11 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { return nil } return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone} + case domain.UpdateEventUserEmojiStatus: + if event.UserID == 0 || !event.EmojiStatus.Valid() { + return nil + } + return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)} case domain.UpdateEventChannelState: if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { return nil diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 9ccb617c..1093453b 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -9,6 +9,9 @@ import ( // tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。 func tgSelfUser(u domain.User) *tg.User { + if u.Deleted { + return &tg.User{ID: u.ID, Deleted: true} + } out := &tg.User{ ID: u.ID, AccessHash: u.AccessHash, @@ -27,6 +30,9 @@ func tgSelfUser(u domain.User) *tg.User { applyTgUserBotFields(out, u) applyTgUserPremiumFields(out, u) applyTgUserColorFields(out, u) + if u.LinkedCommunityID != 0 { + out.SetLinkedCommunityID(u.LinkedCommunityID) + } if photo := tgUserProfilePhoto(u); photo != nil { out.Photo = photo } @@ -34,6 +40,9 @@ func tgSelfUser(u domain.User) *tg.User { } func tgUser(u domain.User) *tg.User { + if u.Deleted { + return &tg.User{ID: u.ID, Deleted: true} + } out := &tg.User{ ID: u.ID, AccessHash: u.AccessHash, @@ -51,6 +60,9 @@ func tgUser(u domain.User) *tg.User { applyTgUserBotFields(out, u) applyTgUserPremiumFields(out, u) applyTgUserColorFields(out, u) + if u.LinkedCommunityID != 0 { + out.SetLinkedCommunityID(u.LinkedCommunityID) + } if photo := tgUserProfilePhoto(u); photo != nil { out.Photo = photo } @@ -79,9 +91,35 @@ func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass { if !u.EmojiStatusActiveAt(now) { return &tg.EmojiStatusEmpty{} } - status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID} - if u.EmojiStatusUntil > 0 { - status.SetUntil(u.EmojiStatusUntil) + return tgUserEmojiStatusValue(u.EmojiStatus()) +} + +// tgUserEmojiStatusValue converts an already validated absolute snapshot. It +// is shared by inline user projections and durable updateUserEmojiStatus. +func tgUserEmojiStatusValue(value domain.UserEmojiStatus) tg.EmojiStatusClass { + if !value.Valid() || value.Empty() { + return &tg.EmojiStatusEmpty{} + } + if collectible := value.Collectible; !collectible.Empty() { + status := &tg.EmojiStatusCollectible{ + CollectibleID: collectible.CollectibleID, + DocumentID: collectible.DocumentID, + Title: collectible.Title, + Slug: collectible.Slug, + PatternDocumentID: collectible.PatternDocumentID, + CenterColor: collectible.CenterColor, + EdgeColor: collectible.EdgeColor, + PatternColor: collectible.PatternColor, + TextColor: collectible.TextColor, + } + if value.Until > 0 { + status.SetUntil(value.Until) + } + return status + } + status := &tg.EmojiStatus{DocumentID: value.DocumentID} + if value.Until > 0 { + status.SetUntil(value.Until) } return status } diff --git a/internal/rpc/convert_users_deleted_test.go b/internal/rpc/convert_users_deleted_test.go new file mode 100644 index 00000000..70e6da21 --- /dev/null +++ b/internal/rpc/convert_users_deleted_test.go @@ -0,0 +1,56 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/iamxvbaba/td/clock" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) { + u := domain.User{ + ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private", + Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000, + PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000, + } + got := tgUser(u) + if got.ID != u.ID || !got.Deleted { + t.Fatalf("deleted user = %+v", got) + } + if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 { + t.Fatalf("deleted user leaked profile state: %+v", got) + } + self := tgSelfUser(u) + if !self.Deleted || self.Self || self.ID != u.ID { + t.Fatalf("deleted self projection = %+v", self) + } +} + +func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) { + viewer := domain.User{ID: 7, FirstName: "Viewer"} + deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000} + r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{ + viewer.ID: viewer, deleted.ID: deleted, + }}}, zaptest.NewLogger(t), clock.System) + + list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{ + Messages: []domain.Message{{ + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID}, + Body: "retained history", + }}, + // Simulate an old denormalized message query row. The authoritative + // Users.ByIDs hydration must replace it, not keep an empty active user. + Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}}, + }) + if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" { + t.Fatalf("history users = %+v, want authoritative tombstone", list.Users) + } + if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID { + t.Fatalf("history TL user = %+v", got) + } +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index eefaa89c..2341b2bb 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -180,6 +180,15 @@ type AuthKeyTargetedSessionBinder interface { PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) } +// ExactLayerTransientSessionBinder is the admission boundary for updates whose +// constructors do not exist in older profiles. Implementations must filter the +// live session index before encoding, skip unknown/not-ready profiles, and must +// never queue the transient payload for later delivery. +type ExactLayerTransientSessionBinder interface { + PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) + PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) +} + // OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout. type OnlineUserProvider interface { IsUserOnline(userID int64) bool @@ -298,7 +307,14 @@ type UserIdentityService interface { type UserPremiumService interface { GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) +} + +// UserEmojiStatusDurableService exposes the aggregate state+event write used +// by account.updateEmojiStatus. The bool is false for lightweight stores that +// require the RPC Updates service to append the event separately. +type UserEmojiStatusDurableService interface { + UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) } // UserColorService 是 UsersService 的个人色板扩展能力。用于 account.updateColor @@ -429,6 +445,13 @@ type UpdatesService interface { RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) } +// UserEmojiStatusUpdatesService is the optional durable settings-update +// extension used by account.updateEmojiStatus. Keeping it separate preserves +// lightweight test/service implementations of the core UpdatesService. +type UserEmojiStatusUpdatesService interface { + RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) +} + // ContactsService 抽象通讯录查询。 type ContactsService interface { GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) @@ -597,6 +620,7 @@ type ChannelsService interface { CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) @@ -710,6 +734,32 @@ type ChannelsService interface { FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) } +// CommunitiesService abstracts the Layer 228 Community aggregation domain. +// Community containers never expose tg types and never own message/read/pts state. +type CommunitiesService interface { + Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error) + Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error) + GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) + ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error) + TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) + SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) + ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) + DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) + DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) + ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) + ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) + Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) + EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error) + EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error) + EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) + EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) + SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) + Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) + SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) + ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) + SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error) +} + // FilesService 抽象文件上传分片、下载与媒体(document/photo)组装。 // 方法只用 domain 类型;rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。 type FilesService interface { @@ -791,6 +841,22 @@ type AIComposeService interface { Compose(ctx context.Context, req domain.AIComposeRequest) (domain.AIComposeResult, error) } +// EphemeralService owns Layer 228 short-lived bot/member state. It must never +// write ordinary messages, dialogs, pts/qts/seq logs or durable update outbox. +type EphemeralService interface { + SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) + SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) + SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) + EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) + EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) + EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) + Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) + DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) + Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) + PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) + ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) +} + // Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 type Deps struct { Auth AuthService @@ -803,10 +869,14 @@ type Deps struct { Help HelpService AccountFreeze AccountFreezeService AICompose AIComposeService + Ephemeral EphemeralService + EphemeralPush store.EphemeralPushBroker + EphemeralReports store.EphemeralReportStore Users UsersService Updates UpdatesService BootstrapUpdates store.BootstrapUpdateJobStore BotAPIUpdates store.BotAPIUpdateStore + BotCallbacks store.BotCallbackRegistryStore Contacts ContactsService Dialogs DialogsService Chatlists ChatlistsService @@ -814,6 +884,7 @@ type Deps struct { Translation TranslationService Stories StoriesService Channels ChannelsService + Communities CommunitiesService Files FilesService Bots BotsService Polls PollsService @@ -871,7 +942,9 @@ type GiftsService interface { UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) + ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) + UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) @@ -879,13 +952,36 @@ type GiftsService interface { ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) CountSaved(ctx context.Context, owner domain.Peer) (int, error) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) - Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) + ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error + ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) + ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) + SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) + Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) + PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) + SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) + ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) + ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) + Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) + AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) + ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) + AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) + BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) + PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) + PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) + DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) + SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error + Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) + TonBalance(ctx context.Context, userID int64) (int64, error) + TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) + IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) + ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error + Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) } // StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。 diff --git a/internal/rpc/dialogs_pinned.go b/internal/rpc/dialogs_pinned.go index d125bbec..811438ec 100644 --- a/internal/rpc/dialogs_pinned.go +++ b/internal/rpc/dialogs_pinned.go @@ -3,16 +3,28 @@ package rpc import ( "context" "fmt" + "sort" "telesrv/internal/domain" ) func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) { - if r == nil || r.deps.Dialogs == nil { + list, err := r.pinnedDialogsBaseList(ctx, userID, folderID) + if err != nil { + return domain.DialogList{}, err + } + return r.withCommunityDialogList(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list) +} + +func (r *Router) pinnedDialogsBaseList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) { + if r == nil { return domain.DialogList{}, nil } key := fmt.Sprintf("%d:%d", userID, folderID) value, err, _ := r.dialogsPinnedSF.Do(key, func() (any, error) { + if r.deps.Dialogs == nil { + return domain.DialogList{}, nil + } return r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{ PinnedOnly: true, HasFolderID: true, @@ -28,3 +40,97 @@ func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID i } return domain.DialogList{}, nil } + +func (r *Router) combinedPinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) { + list, err := r.pinnedDialogsBaseList(ctx, userID, folderID) + if err != nil { + return domain.DialogList{}, err + } + return r.withCollapsedCommunityDialogs(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list) +} + +// combinedPinnedDialogPeers merges ordinary dialogs and collapsed Communities +// by their shared server order. The two persistence implementations deliberately +// store their own rows, but Layer 228 exposes one messages.getPinnedDialogs list. +func combinedPinnedDialogPeers(list domain.DialogList) []domain.Peer { + type item struct { + peer domain.Peer + order int + sequence int + } + items := make([]item, 0, len(list.Dialogs)+len(list.Communities)) + seen := make(map[domain.Peer]struct{}, cap(items)) + appendItem := func(peer domain.Peer, pinned bool, order int) { + if !pinned || peer.ID == 0 { + return + } + if _, ok := seen[peer]; ok { + return + } + seen[peer] = struct{}{} + items = append(items, item{peer: peer, order: order, sequence: len(items)}) + } + for _, dialog := range list.Dialogs { + appendItem(dialog.Peer, dialog.Pinned, dialog.PinnedOrder) + } + for _, community := range list.Communities { + appendItem(domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID}, community.State.Pinned, community.State.PinnedOrder) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].order != items[j].order { + return items[i].order > items[j].order + } + return items[i].sequence < items[j].sequence + }) + out := make([]domain.Peer, 0, len(items)) + for _, item := range items { + out = append(out, item.peer) + } + return out +} + +func (r *Router) ensureCombinedPinCapacity(ctx context.Context, userID int64, folderID int, peer domain.Peer) error { + list, err := r.combinedPinnedDialogsList(ctx, userID, folderID) + if err != nil { + return err + } + peers := combinedPinnedDialogPeers(list) + for _, pinned := range peers { + if pinned == peer { + return nil + } + } + if len(peers) >= domain.PinnedDialogsLimit(folderID, r.userIsPremium(ctx, userID)) { + return domain.ErrPinnedDialogsTooMuch + } + return nil +} + +// promoteCombinedPinnedDialog assigns one collision-free order across ordinary +// dialogs and Communities. It is called after the underlying row is pinned so +// both stores can project the same mixed order without owning each other's data. +func (r *Router) promoteCombinedPinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error { + list, err := r.combinedPinnedDialogsList(ctx, userID, folderID) + if err != nil { + return err + } + current := combinedPinnedDialogPeers(list) + order := make([]domain.Peer, 0, len(current)+1) + order = append(order, peer) + for _, candidate := range current { + if candidate != peer { + order = append(order, candidate) + } + } + if r.deps.Dialogs != nil { + if _, err := r.deps.Dialogs.ReorderPinned(ctx, userID, folderID, order, false); err != nil { + return err + } + } + if folderID == domain.DialogMainFolderID && r.deps.Communities != nil { + if _, err := r.deps.Communities.ReorderPinned(ctx, userID, order, false); err != nil { + return err + } + } + return nil +} diff --git a/internal/rpc/ephemeral.go b/internal/rpc/ephemeral.go new file mode 100644 index 00000000..16b5eda4 --- /dev/null +++ b/internal/rpc/ephemeral.go @@ -0,0 +1,460 @@ +package rpc + +import ( + "context" + "errors" + "unicode/utf8" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "github.com/iamxvbaba/td/tlprofile" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func (r *Router) registerEphemeral(d *tlprofile.Dispatcher) { + registerRPC[*tg.EphemeralSendMessageRequest](d, tlprofile.SemanticMethodEphemeralSendMessage, func(ctx context.Context, request *tg.EphemeralSendMessageRequest) (any, error) { + return r.onEphemeralSendMessage(ctx, request) + }) + registerRPC[*tg.EphemeralDeleteMessageRequest](d, tlprofile.SemanticMethodEphemeralDeleteMessage, func(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (any, error) { + return r.onEphemeralDeleteMessage(ctx, request) + }) + registerRPC[*tg.EphemeralReportMessageRequest](d, tlprofile.SemanticMethodEphemeralReportMessage, func(ctx context.Context, request *tg.EphemeralReportMessageRequest) (any, error) { + return r.onEphemeralReportMessage(ctx, request) + }) + registerRPC[*tg.EphemeralGetCallbackAnswerRequest](d, tlprofile.SemanticMethodEphemeralGetCallbackAnswer, func(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (any, error) { + return r.onEphemeralGetCallbackAnswer(ctx, request) + }) +} + +func (r *Router) onEphemeralSendMessage(ctx context.Context, request *tg.EphemeralSendMessageRequest) (tg.UpdatesClass, error) { + if request == nil || r.deps.Ephemeral == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 { + return nil, peerIDInvalidErr() + } + receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID) + if err != nil { + return nil, internalErr() + } + if !found || !receiver.Bot { + return nil, userBotInvalidErr() + } + content, err := r.domainEphemeralInputContent(ctx, userID, request) + if err != nil { + return nil, err + } + topMessageID, replyID, err := ephemeralReplyFromInput(request.ReplyTo) + if err != nil { + return nil, err + } + queryID, _ := request.GetQueryID() + authKeyID, authKeyOK := AuthKeyIDFrom(ctx) + sessionID, sessionOK := SessionIDFrom(ctx) + if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 { + return nil, internalErr() + } + message, fresh, err := r.deps.Ephemeral.SendFromClient(ctx, domain.SendClientEphemeralRequest{ + SenderUserID: userID, ReceiverBotID: receiver.ID, Peer: peer, + QueryID: queryID, RandomID: request.RandomID, TopMessageID: topMessageID, + ReplyToEphemeralID: replyID, Content: content, + OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, + }) + if err != nil { + return nil, ephemeralRPCError(err) + } + if fresh && r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: receiver.ID, + Kind: domain.BotAPIUpdateMessage, + Peer: message.Peer, + MessageID: message.ID, + Date: message.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(message), + }); err != nil { + r.log.Warn("enqueue bot api ephemeral message", zap.Int64("bot_user_id", receiver.ID), zap.Int("ephemeral_message_id", message.ID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(receiver.ID) + } + } + if fresh { + // OriginDevice belongs to the human sender and must not constrain the + // receiving bot's sessions. + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, Message: message, + }) + } + // A lost create response can be retried after the ephemeral message was + // deleted. The random-id index deliberately returns its tombstone; reflect + // that final fact instead of projecting an impossible empty new message. + if message.Deleted { + return ephemeralDeleteUpdates(message, int(r.clock.Now().Unix())), nil + } + return r.ephemeralMessageUpdates(ctx, userID, message, false) +} + +func (r *Router) onEphemeralGetCallbackAnswer(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return nil, err + } + data, _ := request.GetData() + callback, err := r.deps.Ephemeral.Callback(ctx, userID, device, peer, request.ID, data) + if err != nil { + return nil, ephemeralRPCError(err) + } + queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), callback.BotUserID, userID, botCallbackTimeout) + if err != nil { + r.log.Warn("register shared ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Error(err)) + return nil, internalErr() + } + defer r.callbacks.deregisterContext(context.Background(), callback.BotUserID, queryID) + created, err := r.deps.Ephemeral.PutCallbackAction(ctx, domain.EphemeralCallbackAction{ + QueryID: queryID, BotUserID: callback.BotUserID, UserID: userID, Peer: peer, + MessageID: request.ID, TopMessageID: callback.Message.TopMessageID, Device: callback.Device, CreatedAt: callback.OccurredAt, + ExpiresAt: callback.OccurredAt.Add(domain.EphemeralReplyWindow), + }) + if err != nil || !created { + return nil, internalErr() + } + + botCallback := domain.BotCallbackQuery{ + ID: queryID, BotUserID: callback.BotUserID, UserID: userID, + Peer: peer, MessageID: request.ID, ChatInstance: chatInstanceForPeer(callback.BotUserID, peer), + Data: append([]byte(nil), data...), + } + if r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: callback.BotUserID, + Kind: domain.BotAPIUpdateCallbackQuery, + Peer: peer, + MessageID: request.ID, + Date: int(callback.OccurredAt.Unix()), + Callback: &botCallback, + Ephemeral: domain.NewBotAPIEphemeralPayload(callback.Message), + }); err != nil { + r.log.Warn("enqueue bot api ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Int64("query_id", queryID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(callback.BotUserID) + } + } + + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushCallback, TargetUserID: callback.BotUserID, + Message: callback.Message, Callback: &botCallback, Date: int(callback.OccurredAt.Unix()), + }) + return r.waitBotCallbackAnswer(ctx, callback.BotUserID, queryID, pending) +} + +func (r *Router) onEphemeralDeleteMessage(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (bool, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return false, err + } + receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID) + if err != nil { + return false, internalErr() + } + if !found { + return false, userIDInvalidErr() + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return false, err + } + message, deleted, err := r.deps.Ephemeral.DeleteFromDevice(ctx, userID, receiver.ID, device, peer, request.ID) + if err != nil { + return false, ephemeralRPCError(err) + } + if deleted { + for _, targetUserID := range []int64{message.SenderUserID, message.ReceiverUserID} { + var targetAuthKey [8]byte + if message.OriginDevice.UserID == targetUserID { + targetAuthKey = message.OriginDevice.BusinessAuthKeyID + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: targetUserID, + TargetBusinessAuthKey: targetAuthKey, Message: message, + }) + } + } + return true, nil +} + +func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.EphemeralReportMessageRequest) (tg.ReportResultClass, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return nil, err + } + target, err := r.deps.Ephemeral.ReportTarget(ctx, userID, device, peer, request.ID) + if err != nil { + return nil, ephemeralRPCError(err) + } + if utf8.RuneCountInString(request.Message) > 1024 { + return nil, messageTooLongErr() + } + result, err := reportResultForOption(string(request.Option)) + if err != nil { + return nil, err + } + if _, final := result.(*tg.ReportResultReported); !final { + return result, nil + } + if r.deps.EphemeralReports == nil { + return nil, internalErr() + } + report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now()) + if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil { + r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err)) + return nil, internalErr() + } + return result, nil +} + +func (r *Router) domainEphemeralInputContent(ctx context.Context, userID int64, request *tg.EphemeralSendMessageRequest) (domain.EphemeralContent, error) { + if !utf8.ValidString(request.Message) || utf8.RuneCountInString(request.Message) > domain.MaxMessageTextLength || len(request.Entities) > domain.MaxMessageEntityCount { + return domain.EphemeralContent{}, messageTooLongErr() + } + entities := domainMessageEntitiesForViewer(userID, request.Entities) + if len(entities) != len(request.Entities) || !validEphemeralEntityBounds(request.Message, entities) { + return domain.EphemeralContent{}, tgerr.New(400, "ENTITY_BOUNDS_INVALID") + } + var media *domain.MessageMedia + if request.Media != nil { + resolved, err := r.resolveInputMedia(ctx, userID, request.Media) + if err != nil { + return domain.EphemeralContent{}, err + } + if !ephemeralMediaAllowed(resolved) { + return domain.EphemeralContent{}, mediaTypeInvalidErr() + } + media = resolved + } + var markup *domain.MessageReplyMarkup + if request.ReplyMarkup != nil { + var err error + markup, err = domainReplyMarkupForSender(request.ReplyMarkup, false) + if err != nil { + return domain.EphemeralContent{}, replyMarkupErr(err) + } + } + // Layer 228 exposes f_rich_message on the request but its + // ephemeralMessage result has no field capable of carrying that content. + // Official TDesktop always sends an empty InputRichMessage here. Reject the + // otherwise lossy shape instead of acknowledging content the receiver could + // never reconstruct. + if request.RichMessage != nil { + return domain.EphemeralContent{}, inputConstructorInvalidErr() + } + if request.Message == "" && media == nil { + return domain.EphemeralContent{}, messageEmptyErr() + } + content := domain.EphemeralContent{Message: request.Message, Entities: entities, Media: media, ReplyMarkup: markup} + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralContent{}, inputRequestInvalidErr() + } + return content, nil +} + +func ephemeralReplyFromInput(reply tg.InputReplyToClass) (topMessageID, ephemeralID int, err error) { + switch value := reply.(type) { + case nil: + return 0, 0, nil + case *tg.InputReplyToEphemeralMessage: + if value.ID <= 0 || value.ID > domain.MaxMessageBoxID { + return 0, 0, messageIDInvalidErr() + } + return 0, value.ID, nil + case *tg.InputReplyToMessage: + topMessageID = value.ReplyToMsgID + if explicit, ok := value.GetTopMsgID(); ok { + topMessageID = explicit + } + if topMessageID <= 0 || topMessageID > domain.MaxMessageBoxID { + return 0, 0, messageIDInvalidErr() + } + if value.ReplyToPeerID != nil || value.QuoteText != "" || len(value.QuoteEntities) != 0 || value.QuoteOffset != 0 || + value.MonoforumPeerID != nil || value.TodoItemID != 0 || len(value.PollOption) != 0 { + return 0, 0, inputConstructorInvalidErr() + } + return topMessageID, 0, nil + default: + return 0, 0, inputConstructorInvalidErr() + } +} + +func validEphemeralEntityBounds(message string, entities []domain.MessageEntity) bool { + utf16Length := 0 + for _, runeValue := range message { + utf16Length++ + if runeValue > 0xffff { + utf16Length++ + } + } + for _, entity := range entities { + if entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || entity.Length > utf16Length-entity.Offset { + return false + } + } + return true +} + +func ephemeralMediaAllowed(media *domain.MessageMedia) bool { + if media == nil || media.IsZero() { + return false + } + switch media.Kind { + case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument, domain.MessageMediaKindContact, + domain.MessageMediaKindGeo, domain.MessageMediaKindVenue: + return true + default: + return false + } +} + +func (r *Router) ephemeralMessageUpdates(ctx context.Context, viewerUserID int64, message domain.EphemeralMessage, edited bool) (*tg.Updates, error) { + if r.deps.Users == nil || r.deps.Channels == nil { + return nil, internalErr() + } + users, err := r.deps.Users.ByIDs(ctx, viewerUserID, []int64{message.SenderUserID, message.ReceiverUserID}) + if err != nil { + return nil, internalErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, message.Peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + wire := tgEphemeralMessage(viewerUserID, message) + var update tg.UpdateClass = &tg.UpdateNewEphemeralMessage{Message: wire} + if edited { + update = &tg.UpdateEditEphemeralMessage{Message: wire} + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: tgUsersForViewer(viewerUserID, users), + Chats: []tg.ChatClass{tgChannelChatForView(viewerUserID, view)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func ephemeralDeleteUpdates(message domain.EphemeralMessage, date int) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateDeleteEphemeralMessages{ + Peer: tgPeer(message.Peer), IDs: []int{message.ID}, + }}, + Date: date, + Seq: 0, + } +} + +func tgEphemeralMessage(viewerUserID int64, message domain.EphemeralMessage) tg.EphemeralMessage { + out := tg.EphemeralMessage{ + Out: viewerUserID == message.SenderUserID, + ID: message.ID, + FromID: &tg.PeerUser{UserID: message.SenderUserID}, + PeerID: tgPeer(message.Peer), + ReceiverID: message.ReceiverUserID, + Date: message.Date, + Message: message.Content.Message, + } + if message.TopMessageID > 0 { + out.SetTopMsgID(message.TopMessageID) + } + if len(message.Content.Entities) != 0 { + out.SetEntities(tgMessageEntities(message.Content.Entities)) + } + if message.Content.Media != nil && !message.Content.Media.IsZero() { + out.SetMedia(tgMessageMedia(message.Content.Media)) + } + if message.Content.ReplyMarkup != nil && !message.Content.ReplyMarkup.IsZero() { + out.SetReplyMarkup(tgReplyMarkup(message.Content.ReplyMarkup)) + } + if message.ReplyToEphemeralID > 0 { + reply := &tg.MessageReplyHeader{ReplyToEphemeral: true} + reply.SetReplyToMsgID(message.ReplyToEphemeralID) + if message.TopMessageID > 0 { + reply.ForumTopic = true + reply.SetReplyToTopID(message.TopMessageID) + } + out.SetReplyTo(reply) + } + return out +} + +func ephemeralDeviceFromContext(ctx context.Context, userID int64) (domain.EphemeralDevice, error) { + authKeyID, authOK := AuthKeyIDFrom(ctx) + sessionID, sessionOK := SessionIDFrom(ctx) + if !authOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 { + return domain.EphemeralDevice{}, internalErr() + } + return domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, nil +} + +func ephemeralRPCError(err error) error { + switch { + case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), + errors.Is(err, domain.ErrEphemeralDeleted), errors.Is(err, domain.ErrEphemeralReplyExpired): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralPeerInvalid): + return peerIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralSenderInvalid), errors.Is(err, domain.ErrEphemeralReceiverInvalid): + return userIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralCommandInvalid): + return tgerr.New(400, "BOT_COMMAND_INVALID") + case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch): + return tgerr.New(403, "CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrEphemeralCallbackInvalid): + return dataInvalidErr() + case errors.Is(err, domain.ErrEphemeralInvalid), errors.Is(err, domain.ErrEphemeralRandomIDConflict), + errors.Is(err, domain.ErrEphemeralVersionConflict): + return inputRequestInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/ephemeral_push.go b/internal/rpc/ephemeral_push.go new file mode 100644 index 00000000..62216ad7 --- /dev/null +++ b/internal/rpc/ephemeral_push.go @@ -0,0 +1,107 @@ +package rpc + +import ( + "context" + "time" + + "github.com/iamxvbaba/td/proto" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap" + + "telesrv/internal/store" +) + +const ephemeralPushSubscribeRetry = time.Second + +func (r *Router) RunEphemeralPushSubscriber(ctx context.Context) { + if r == nil || r.deps.EphemeralPush == nil { + return + } + for { + err := r.deps.EphemeralPush.SubscribeEphemeralPushes(ctx, func(ctx context.Context, event store.EphemeralPush) { + if event.SourceID == "" || event.SourceID == r.instanceID { + return + } + r.deliverEphemeralPushLocal(ctx, event) + }) + if ctx.Err() != nil { + return + } + if err != nil { + r.log.Warn("ephemeral push subscriber stopped", zap.Error(err)) + } + select { + case <-ctx.Done(): + return + case <-time.After(ephemeralPushSubscribeRetry): + } + } +} + +func (r *Router) publishEphemeralPush(ctx context.Context, event store.EphemeralPush) { + if r == nil || event.TargetUserID <= 0 { + return + } + event.SourceID = r.instanceID + if event.Date <= 0 { + event.Date = int(r.clock.Now().Unix()) + } + r.deliverEphemeralPushLocal(ctx, event) + if r.deps.EphemeralPush != nil { + if err := r.deps.EphemeralPush.PublishEphemeralPush(ctx, event); err != nil { + r.log.Debug("publish ephemeral push", zap.String("kind", string(event.Kind)), zap.Int64("target_user_id", event.TargetUserID), zap.Error(err)) + } + } +} + +func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.EphemeralPush) { + if r == nil || r.deps.Sessions == nil || event.TargetUserID <= 0 || event.Message.ID <= 0 { + return + } + if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(event.TargetUserID) { + return + } + binder, ok := r.deps.Sessions.(ExactLayerTransientSessionBinder) + if !ok { + return + } + var updates tg.UpdatesClass + switch event.Kind { + case store.EphemeralPushNew, store.EphemeralPushEdit: + if event.TargetUserID != event.Message.ReceiverUserID || event.Message.Deleted { + return + } + built, err := r.ephemeralMessageUpdates(ctx, event.TargetUserID, event.Message, event.Kind == store.EphemeralPushEdit) + if err != nil { + return + } + updates = built + case store.EphemeralPushDelete: + if !event.Message.Deleted || (event.TargetUserID != event.Message.SenderUserID && event.TargetUserID != event.Message.ReceiverUserID) { + return + } + updates = ephemeralDeleteUpdates(event.Message, event.Date) + case store.EphemeralPushCallback: + callback := event.Callback + if callback == nil || callback.BotUserID != event.TargetUserID || callback.MessageID != event.Message.ID || callback.Peer != event.Message.Peer { + return + } + update := &tg.UpdateBotCallbackQuery{ + QueryID: callback.ID, UserID: callback.UserID, Peer: tgPeer(callback.Peer), + MsgID: callback.MessageID, ChatInstance: callback.ChatInstance, + } + update.SetData(callback.Data) + updates = &tg.Updates{Updates: []tg.UpdateClass{update}, Date: event.Date} + default: + return + } + minLayer := 228 + if event.Kind == store.EphemeralPushCallback { + minLayer = 225 + } + if event.TargetBusinessAuthKey != ([8]byte{}) { + _, _ = binder.PushToUserAuthKeyTransientAtLeastLayer(ctx, event.TargetUserID, event.TargetBusinessAuthKey, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout) + return + } + _, _ = binder.PushToUserTransientAtLeastLayer(ctx, event.TargetUserID, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout) +} diff --git a/internal/rpc/ephemeral_push_test.go b/internal/rpc/ephemeral_push_test.go new file mode 100644 index 00000000..7ffd17e6 --- /dev/null +++ b/internal/rpc/ephemeral_push_test.go @@ -0,0 +1,220 @@ +package rpc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/proto" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type ephemeralPushChannels struct { + ChannelsService + view domain.ChannelView + calls int +} + +func (s *ephemeralPushChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + s.calls++ + return s.view, nil +} + +type ephemeralPushSessions struct { + SessionBinder + OnlineUserProvider + mu sync.Mutex + online bool + broadcasts []ephemeralPushCapture + targeted []ephemeralPushCapture +} + +type ephemeralPushCapture struct { + userID int64 + authKey [8]byte + minLayer int + message tg.UpdatesClass +} + +func (s *ephemeralPushSessions) IsUserOnline(int64) bool { return s.online } + +func (s *ephemeralPushSessions) PushToUserTransientAtLeastLayer(_ context.Context, userID int64, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, minLayer: minLayer, message: message}) + return 1, nil +} + +func (s *ephemeralPushSessions) PushToUserAuthKeyTransientAtLeastLayer(_ context.Context, userID int64, authKey [8]byte, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, minLayer: minLayer, message: message}) + return 1, nil +} + +func (s *ephemeralPushSessions) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.broadcasts), len(s.targeted) +} + +type inMemoryEphemeralBroker struct { + mu sync.Mutex + subscribers []func(context.Context, store.EphemeralPush) + registered chan struct{} + published []store.EphemeralPush +} + +func newInMemoryEphemeralBroker() *inMemoryEphemeralBroker { + return &inMemoryEphemeralBroker{registered: make(chan struct{}, 8)} +} + +func (b *inMemoryEphemeralBroker) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error { + b.mu.Lock() + b.published = append(b.published, event) + handlers := append([]func(context.Context, store.EphemeralPush){}, b.subscribers...) + b.mu.Unlock() + for _, handler := range handlers { + handler(ctx, event) + } + return nil +} + +func (b *inMemoryEphemeralBroker) SubscribeEphemeralPushes(ctx context.Context, handler func(context.Context, store.EphemeralPush)) error { + b.mu.Lock() + b.subscribers = append(b.subscribers, handler) + b.mu.Unlock() + b.registered <- struct{}{} + <-ctx.Done() + return ctx.Err() +} + +func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + broker := newInMemoryEphemeralBroker() + users := mapUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Bot", Bot: true}, + 2001: {ID: 2001, FirstName: "Alice"}, + }} + view := domain.ChannelView{ + Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true}, + Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive}, + } + channels1, channels2 := &ephemeralPushChannels{view: view}, &ephemeralPushChannels{view: view} + sessions1, sessions2 := &ephemeralPushSessions{online: true}, &ephemeralPushSessions{online: true} + r1 := New(Config{InstanceID: "one"}, Deps{Users: users, Channels: channels1, Sessions: sessions1, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System) + r2 := New(Config{InstanceID: "two"}, Deps{Users: users, Channels: channels2, Sessions: sessions2, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System) + go r1.RunEphemeralPushSubscriber(ctx) + go r2.RunEphemeralPushSubscriber(ctx) + for range 2 { + select { + case <-broker.registered: + case <-time.After(time.Second): + t.Fatal("subscriber did not register") + } + } + + now := time.Now() + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + r1.publishEphemeralPush(ctx, store.EphemeralPush{Kind: store.EphemeralPushNew, TargetUserID: 2001, Message: message}) + if broadcast, targeted := sessions1.counts(); broadcast != 1 || targeted != 0 { + t.Fatalf("source delivery broadcast=%d targeted=%d", broadcast, targeted) + } + if broadcast, targeted := sessions2.counts(); broadcast != 1 || targeted != 0 { + t.Fatalf("remote delivery broadcast=%d targeted=%d", broadcast, targeted) + } + if sessions1.broadcasts[0].minLayer != 228 || sessions2.broadcasts[0].minLayer != 228 { + t.Fatalf("min layers source=%d remote=%d", sessions1.broadcasts[0].minLayer, sessions2.broadcasts[0].minLayer) + } + if len(broker.published) != 1 || broker.published[0].SourceID != "one" { + t.Fatalf("published=%+v", broker.published) + } + + key := [8]byte{9, 8, 7} + message.Deleted = true + message.Version++ + message.Content = domain.EphemeralContent{} + r2.deliverEphemeralPushLocal(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: 2001, + TargetBusinessAuthKey: key, Message: message, Date: int(time.Now().Unix()), + }) + _, targeted := sessions2.counts() + if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].minLayer != 228 { + t.Fatalf("targeted=%+v", sessions2.targeted) + } + deletedUpdates, ok := sessions2.targeted[0].message.(*tg.Updates) + if !ok || deletedUpdates.Seq != 0 || len(deletedUpdates.Updates) != 1 { + t.Fatalf("delete updates=%#v", sessions2.targeted[0].message) + } + deleted, ok := deletedUpdates.Updates[0].(*tg.UpdateDeleteEphemeralMessages) + if !ok || len(deleted.IDs) != 1 || deleted.IDs[0] != message.ID { + t.Fatalf("delete update=%#v", deletedUpdates.Updates[0]) + } +} + +func TestEphemeralMessageUpdatesAreTransientAndPtsFree(t *testing.T) { + now := time.Now() + router := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Bot", Bot: true}, + 2001: {ID: 2001, FirstName: "Alice"}, + }}, + Channels: &ephemeralPushChannels{view: domain.ChannelView{ + Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true}, + Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive}, + }}, + }, zaptest.NewLogger(t), clock.System) + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + updates, err := router.ephemeralMessageUpdates(context.Background(), 2001, message, false) + if err != nil || updates.Seq != 0 || len(updates.Updates) != 1 { + t.Fatalf("updates=%#v err=%v", updates, err) + } + if _, ok := updates.Updates[0].(*tg.UpdateNewEphemeralMessage); !ok { + t.Fatalf("update type=%T", updates.Updates[0]) + } + deleted := ephemeralDeleteUpdates(domain.EphemeralMessage{ID: message.ID, Peer: message.Peer}, int(now.Unix())) + if deleted.Seq != 0 { + t.Fatalf("delete seq=%d", deleted.Seq) + } +} + +func TestEphemeralPushOfflineSkipsHydration(t *testing.T) { + channels := &ephemeralPushChannels{view: domain.ChannelView{Channel: domain.Channel{ID: 3001}}} + sessions := &ephemeralPushSessions{online: false} + now := time.Now() + router := New(Config{InstanceID: "offline"}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{}}, Channels: channels, Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + router.deliverEphemeralPushLocal(context.Background(), store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: 2001, + Message: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + }, + }) + if channels.calls != 0 { + t.Fatalf("offline push performed %d channel hydrations", channels.calls) + } + if broadcast, targeted := sessions.counts(); broadcast != 0 || targeted != 0 { + t.Fatalf("offline delivery broadcast=%d targeted=%d", broadcast, targeted) + } +} diff --git a/internal/rpc/ephemeral_rpc_test.go b/internal/rpc/ephemeral_rpc_test.go new file mode 100644 index 00000000..a9661b4c --- /dev/null +++ b/internal/rpc/ephemeral_rpc_test.go @@ -0,0 +1,96 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type ephemeralReportChannels struct { + ChannelsService + view domain.ChannelView +} + +func (s *ephemeralReportChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + return s.view, nil +} + +type ephemeralReportService struct { + EphemeralService + target domain.EphemeralMessage + calls int +} + +func (s *ephemeralReportService) ReportTarget(_ context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) { + s.calls++ + if userID != s.target.ReceiverUserID || device.UserID != userID || device.BusinessAuthKeyID != s.target.OriginDevice.BusinessAuthKeyID || + peer != s.target.Peer || id != s.target.ID { + return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden + } + return s.target, nil +} + +func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) { + const userID int64 = 2001 + const channelID int64 = 3001 + now := time.Now() + authKey := [8]byte{1, 2, 3} + target := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + SenderUserID: 1001, ReceiverUserID: userID, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "abuse"}, + OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99}, + PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + reports := memory.NewEphemeralReportStore() + ephemeral := &ephemeralReportService{target: target} + channels := &ephemeralReportChannels{view: domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive}, + }} + router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99) + request := &tg.EphemeralReportMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID, + } + + result, err := router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultChooseOption); !ok || len(reports.Reports()) != 0 { + t.Fatalf("initial result=%T reports=%+v", result, reports.Reports()) + } + request.Option = []byte("other") + result, err = router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultAddComment); !ok || len(reports.Reports()) != 0 { + t.Fatalf("comment result=%T reports=%+v", result, reports.Reports()) + } + request.Option, request.Message = []byte("spam"), "evidence comment" + for range 2 { + result, err = router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultReported); !ok { + t.Fatalf("final result=%T", result) + } + } + stored := reports.Reports() + if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" { + t.Fatalf("reports=%+v", stored) + } + if ephemeral.calls != 4 { + t.Fatalf("ReportTarget calls=%d", ephemeral.calls) + } +} diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 3fcdd290..d429bf1f 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -129,6 +129,10 @@ func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") } func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") } +func allowPaymentRequiredErr(stars int64) error { + return tgerr.New(403, fmt.Sprintf("ALLOW_PAYMENT_REQUIRED_%d", stars)) +} + func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") } func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") } @@ -137,6 +141,10 @@ func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOU func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") } +func formExpiredErr() error { return tgerr.New(400, "FORM_EXPIRED") } + +func purposeInvalidErr() error { return tgerr.New(400, "PURPOSE_INVALID") } + func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") } func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } diff --git a/internal/rpc/help.go b/internal/rpc/help.go index eced459d..011d4b43 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -7,6 +7,7 @@ import ( "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tlprofile" + "telesrv/internal/branding" androidcompat "telesrv/internal/compat/android" ioscompat "telesrv/internal/compat/ios" "telesrv/internal/compat/tdesktop" @@ -21,7 +22,10 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) { return tdesktop.NearestDC(r.cfg.DC), nil }) registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) { - return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil + return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil + }) + registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) { + return r.onHelpSaveAppLog(ctx) }) registerRPC[*tg.HelpGetAppUpdateRequest](d, tlprofile.SemanticMethodHelpGetAppUpdate, func(ctx context.Context, layerRequest *tg.HelpGetAppUpdateRequest) (any, error) { source := layerRequest. @@ -113,6 +117,21 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) { }) } +// onHelpSaveAppLog 为官方客户端的 fire-and-forget 应用遥测提供有界兼容应答。 +// telesrv 当前不运营遥测产品,因此不读取、记录或持久化事件内容;请求已在 exact +// Layer admission 处受 wire/vector/aggregate/depth 限制。该方法按 TL 访问约束允许 +// 未授权连接调用,但已登录 bot 必须拒绝。 +func (r *Router) onHelpSaveAppLog(ctx context.Context) (bool, error) { + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if authorized && r.userIsBot(ctx, userID) { + return false, botMethodInvalidErr() + } + return true, nil +} + func (r *Router) onHelpGetConfig(ctx context.Context) (*tg.Config, error) { config := tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL) userID, authorized, err := r.currentUserID(ctx) @@ -164,7 +183,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis // 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。 func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { promo := &tg.HelpPremiumPromo{ - StatusText: "Telegram Premium is not active on this account.", + StatusText: branding.PremiumName + " is not active on this account.", StatusEntities: []tg.MessageEntityClass{}, VideoSections: []string{}, Videos: []tg.DocumentClass{}, @@ -181,7 +200,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm } if u.PremiumActiveAt(r.clock.Now().Unix()) { until := time.Unix(int64(u.PremiumUntil), 0) - promo.StatusText = "Telegram Premium is active until " + until.Format("2006-01-02") + "." + promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "." } return promo, nil } diff --git a/internal/rpc/help_save_app_log_test.go b/internal/rpc/help_save_app_log_test.go new file mode 100644 index 00000000..c58f12e7 --- /dev/null +++ b/internal/rpc/help_save_app_log_test.go @@ -0,0 +1,74 @@ +package rpc + +import ( + "context" + "fmt" + "testing" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "github.com/iamxvbaba/td/tlprofile" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestHelpSaveAppLogCompatibilityAckAcrossExactProfiles(t *testing.T) { + r := New(Config{}, Deps{Auth: &captureAuthService{}}, zaptest.NewLogger(t), clock.System) + requests := map[string]*tg.HelpSaveAppLogRequest{ + "empty": {}, + "android_device_stat": { + Events: []tg.InputAppEvent{{ + Time: 1_721_234_567.25, + Type: "android_sdcard_exists", + Peer: 1, + Data: &tg.JSONBool{Value: true}, + }}, + }, + } + contexts := map[string]context.Context{ + "unauthenticated": context.Background(), + "user": WithUserID(context.Background(), 42), + } + + for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ { + for contextName, ctx := range contexts { + for requestName, req := range requests { + name := fmt.Sprintf("layer_%d/%s/%s", profile, contextName, requestName) + t.Run(name, func(t *testing.T) { + for attempt := 1; attempt <= 2; attempt++ { + result, method := dispatchExactLayerRPCTest(t, r, ctx, profile, req) + if method != "help.saveAppLog" { + t.Fatalf("attempt %d method = %q, want help.saveAppLog", attempt, method) + } + if value, ok := dispatchCanonicalValue(result).(bool); !ok || !value { + t.Fatalf("attempt %d response = %#v (%T), want true", attempt, dispatchCanonicalValue(result), result) + } + } + }) + } + } + } +} + +func TestHelpSaveAppLogRejectsBot(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + bot, err := users.Create(ctx, domain.User{ + Phone: "+10000000001", + FirstName: "TelemetryBot", + AccessHash: 101, + Bot: true, + }) + if err != nil { + t.Fatal(err) + } + r := New(Config{}, Deps{Users: appusers.NewService(users)}, zaptest.NewLogger(t), clock.System) + + if _, err := r.onHelpSaveAppLog(WithUserID(ctx, bot.ID)); !tgerr.Is(err, "BOT_METHOD_INVALID") { + t.Fatalf("bot saveAppLog err = %v, want BOT_METHOD_INVALID", err) + } +} diff --git a/internal/rpc/messages_bot_longtail.go b/internal/rpc/messages_bot_longtail.go index 68cf4f81..513122a8 100644 --- a/internal/rpc/messages_bot_longtail.go +++ b/internal/rpc/messages_bot_longtail.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "fmt" "strconv" "strings" "unicode/utf8" @@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages if err != nil { return nil, messageSendErr(err) } + r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res) var users []tg.UserClass var chats []tg.ChatClass if !res.Duplicate { @@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if !found || !botUser.Bot { return nil, botInvalidErr() } - webAppReqID, ok := req.GetWebappReqID() - if !ok || webAppReqID == "" { - return nil, buttonDataInvalidErr() - } - button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) - if err != nil { - return nil, internalErr() - } - if !found || button.ButtonID != req.ButtonID { - return nil, buttonDataInvalidErr() + webAppReqID, fromWebApp := req.GetWebappReqID() + idempotencyKey := webAppReqID + var button domain.BotRequestedWebViewButton + if fromWebApp { + if webAppReqID == "" { + return nil, buttonDataInvalidErr() + } + var found bool + button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + if err != nil { + return nil, internalErr() + } + if !found || button.ButtonID != req.ButtonID { + return nil, buttonDataInvalidErr() + } + } else { + idempotencyKey = "message:" + strconv.Itoa(req.MsgID) + button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID) + if err != nil { + return nil, err + } } if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity { return nil, buttonDataInvalidErr() @@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if err != nil { return nil, err } - if !requestedPeerTypeMatches(button.PeerType, resolved) { + if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil { + return nil, internalErr() + } else if !matches { return nil, buttonDataInvalidErr() } peers = append(peers, resolved) } + details, err := r.requestedPeerDetails(ctx, userID, peers, button) + if err != nil { + return nil, internalErr() + } recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID) if err != nil { return nil, err @@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ SenderUserID: userID, RecipientUserID: botUser.ID, - RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers), + RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers), Media: &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ - ButtonID: button.ButtonID, - Peers: peers, + ButtonID: button.ButtonID, + Peers: peers, + Details: details, + NameRequested: button.NameRequested, + UsernameRequested: button.UsernameRequested, + PhotoRequested: button.PhotoRequested, }, }, }, @@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if err != nil { return nil, internalErr() } - _ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res) + if fromWebApp { + _ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + } var users []tg.UserClass var chats []tg.ChatClass if !res.Duplicate { @@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil } +type requestedPeerPhotoProvider interface { + GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) +} + +func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) { + details := make([]domain.MessageRequestedPeerDetails, len(peers)) + for i, peer := range peers { + details[i].Peer = peer + } + if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested { + return details, nil + } + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, peer := range peers { + addDomainPeerRef(peer, 0, userIDs, channelIDs) + } + cache := newViewerPeerCache(r) + users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs)) + channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs)) + userByID := make(map[int64]domain.User, len(users)) + channelByID := make(map[int64]domain.Channel, len(channels)) + photoIDs := make([]int64, 0, len(peers)) + for _, user := range users { + userByID[user.ID] = user + if button.PhotoRequested && user.PhotoID != 0 { + photoIDs = append(photoIDs, user.PhotoID) + } + } + for _, channel := range channels { + channelByID[channel.ID] = channel + if button.PhotoRequested && channel.PhotoID != 0 { + photoIDs = append(photoIDs, channel.PhotoID) + } + } + photoByID := make(map[int64]domain.Photo, len(photoIDs)) + if len(photoIDs) > 0 { + provider, ok := r.deps.Files.(requestedPeerPhotoProvider) + if !ok { + return nil, fmt.Errorf("requested peer photo provider unavailable") + } + photos, err := provider.GetPhotos(ctx, photoIDs) + if err != nil { + return nil, err + } + for _, photo := range photos { + photoByID[photo.ID] = photo + } + } + for i, peer := range peers { + detail := &details[i] + switch peer.Type { + case domain.PeerTypeUser: + user, ok := userByID[peer.ID] + if !ok { + return nil, fmt.Errorf("requested user %d not hydrated", peer.ID) + } + if button.NameRequested { + detail.FirstName, detail.LastName = user.FirstName, user.LastName + } + if button.UsernameRequested { + detail.Username = user.Username + } + if button.PhotoRequested && user.PhotoID != 0 { + photo, ok := photoByID[user.PhotoID] + if !ok { + return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID) + } + detail.Photo = &photo + } + case domain.PeerTypeChannel: + channel, ok := channelByID[peer.ID] + if !ok { + return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID) + } + if button.NameRequested { + detail.Title = channel.Title + } + if button.UsernameRequested { + detail.Username = channel.Username + } + if button.PhotoRequested && channel.PhotoID != 0 { + photo, ok := photoByID[channel.PhotoID] + if !ok { + return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID) + } + detail.Photo = &photo + } + } + } + return details, nil +} + +func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) { + if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 { + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + message, found, err := r.lookupOwnerMessage(ctx, userID, messageID) + if err != nil { + return domain.BotRequestedWebViewButton{}, internalErr() + } + if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || + message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil || + message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard { + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + for _, row := range message.ReplyMarkup.Keyboard { + for _, item := range row { + if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID { + continue + } + return domain.BotRequestedWebViewButton{ + BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID, + PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter, + NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested, + PhotoRequested: item.PhotoRequested, + }, nil + } + } + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() +} + func requestedPeerTypeMatches(kind string, peer domain.Peer) bool { switch kind { case "user", "": @@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool { } } +func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) { + if !requestedPeerTypeMatches(button.PeerType, peer) { + return false, nil + } + filter := button.PeerFilter + if filter == nil { + return true, nil + } + if peer.Type == domain.PeerTypeUser { + if r.deps.Users == nil { + return false, nil + } + user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID) + if err != nil || !found { + return false, err + } + if filter.UserIsBotSet && user.Bot != filter.UserIsBot { + return false, nil + } + if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium { + return false, nil + } + return true, nil + } + if r.deps.Channels == nil { + return false, nil + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return false, err + } + channel := view.Channel + if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) { + return false, nil + } + if button.PeerType == "broadcast" && !channel.Broadcast { + return false, nil + } + if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername { + return false, nil + } + if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum { + return false, nil + } + if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator { + return false, nil + } + if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) { + return false, nil + } + if filter.BotIsMember || filter.BotAdminRights != nil { + botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID) + if err != nil { + return false, err + } + if botMember.Status != domain.ChannelMemberActive { + return false, nil + } + if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) { + return false, nil + } + } + return true, nil +} + +func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool { + if member.Role == domain.ChannelRoleCreator { + return true + } + if member.Role != domain.ChannelRoleAdmin { + return false + } + rights := member.AdminRights + return (!required.Anonymous || rights.Anonymous) && + (!required.ManageChat || rights.ManageChat) && + (!required.DeleteMessages || rights.DeleteMessages) && + (!required.ManageVideoChats || rights.ManageCall) && + (!required.RestrictMembers || rights.BanUsers) && + (!required.PromoteMembers || rights.AddAdmins) && + (!required.ChangeInfo || rights.ChangeInfo) && + (!required.InviteUsers || rights.InviteUsers) && + (!required.PostStories || rights.PostStories) && + (!required.EditStories || rights.EditStories) && + (!required.DeleteStories || rights.DeleteStories) && + (!required.PostMessages || rights.PostMessages) && + (!required.EditMessages || rights.EditMessages) && + (!required.PinMessages || rights.PinMessages) && + (!required.ManageTopics || rights.ManageTopics) && + (!required.ManageDirectMessages || rights.ManageDirectMessages) +} + func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 { parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)} for _, peer := range peers { diff --git a/internal/rpc/messages_bot_longtail_rpc_test.go b/internal/rpc/messages_bot_longtail_rpc_test.go index 7f92bd87..4d999d68 100644 --- a/internal/rpc/messages_bot_longtail_rpc_test.go +++ b/internal/rpc/messages_bot_longtail_rpc_test.go @@ -11,11 +11,13 @@ import ( "telesrv/internal/domain" "telesrv/internal/store" + "telesrv/internal/store/memory" ) func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { ctx := context.Background() f := newInlineBotRPCTestFixture(t) + f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore() ownerCtx := WithUserID(ctx, f.owner.ID) updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ @@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID { t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID) } + botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0) + if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil || + botAPIEvents[0].Message.Media.ServiceAction == nil || + botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil || + botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` { + t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err) + } botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{ HasPeer: true, @@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing } } +func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore() + ownerCtx := WithUserID(ctx, f.owner.ID) + button := domain.MarkupButton{ + Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77, + RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true, + } + requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{ + SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose", + ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}}, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("send request message: %v", err) + } + if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{ + Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID, + RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)}, + }); err != nil { + t.Fatalf("send requested peer: %v", err) + } + events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0) + if err != nil || len(events) != 1 { + t.Fatalf("bot api requested-peer events=%#v err=%v", events, err) + } + action := events[0].Message.Media.ServiceAction.RequestedPeer + if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID || + len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName || + !action.NameRequested || !action.UsernameRequested { + t.Fatalf("requested-peer action=%#v", action) + } +} + func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) { ctx := context.Background() f := newInlineBotRPCTestFixture(t) diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go index b9f98689..4c69995d 100644 --- a/internal/rpc/messages_dialogs.go +++ b/internal/rpc/messages_dialogs.go @@ -3,10 +3,12 @@ package rpc import ( "context" "errors" + "unicode/utf8" + "github.com/iamxvbaba/td/tg" "go.uber.org/zap" + "telesrv/internal/domain" - "unicode/utf8" ) func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { @@ -159,8 +161,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee if len(req.Entities) > maxMessageEntityCount { return domain.DialogDraft{}, limitInvalidErr() } - if !req.SuggestedPost.Zero() { - return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if err != nil { + return domain.DialogDraft{}, err + } + if hasSuggestedPost { + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } + if _, _, resolveErr := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID); resolveErr != nil { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } } replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) if err != nil { @@ -182,17 +194,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee topMessageID = replyTo.TopMessageID } return domain.DialogDraft{ - Peer: peer, - TopMessageID: topMessageID, - Date: date, - NoWebpage: req.NoWebpage, - InvertMedia: req.InvertMedia, - Message: req.Message, - Entities: domainMessageEntities(req.Entities), - ReplyTo: replyTo, - WebPage: webpage, - Effect: req.Effect, - RichMessage: richMessage, + Peer: peer, + TopMessageID: topMessageID, + Date: date, + NoWebpage: req.NoWebpage, + InvertMedia: req.InvertMedia, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + ReplyTo: replyTo, + WebPage: webpage, + Effect: req.Effect, + SuggestedPost: suggestedPost, + RichMessage: richMessage, }, nil } @@ -560,6 +573,50 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages if folderPeer, ok := req.Peer.(*tg.InputDialogPeerFolder); ok { return r.toggleArchiveFolderPin(ctx, userID, folderPeer.FolderID, req.GetPinned()) } + if community, ok, err := r.communityDialogPeerFromInput(ctx, userID, req.Peer); ok { + if err != nil { + return false, err + } + pinned := req.GetPinned() + peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID} + if pinned && !community.State.Pinned { + if err := r.ensureCombinedPinCapacity(ctx, userID, domain.DialogMainFolderID, peer); err != nil { + if errors.Is(err, domain.ErrPinnedDialogsTooMuch) { + return false, pinnedTooMuchErr() + } + return false, internalErr() + } + } + changed, err := r.deps.Communities.SetPinned(ctx, userID, community.Community.ID, pinned) + if err != nil { + return false, communityErr(err) + } + if !changed { + return true, nil + } + if pinned { + if err := r.promoteCombinedPinnedDialog(ctx, userID, domain.DialogMainFolderID, peer); err != nil { + return false, internalErr() + } + } + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peer, pinned, domain.DialogMainFolderID, rawAuthKeyIDForOrigin(ctx), sessionID) + if err != nil { + return false, internalErr() + } + date, recorded = state.Date, event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateDialogPinned{Pinned: pinned, Peer: tgDialogPeer(peer)}}, recorded), + Chats: []tg.ChatClass{tgCommunityChat(community)}, Date: date, + }) + return true, nil + } peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) if err != nil { return false, err @@ -571,6 +628,25 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages if r.deps.Dialogs == nil { return true, nil } + if pinned { + folderID := domain.DialogMainFolderID + current, err := r.deps.Dialogs.GetPeerDialogs(ctx, userID, peers) + if err != nil { + return false, internalErr() + } + for _, dialog := range current.Dialogs { + if dialog.Peer == peers[0] { + folderID = dialog.FolderID + break + } + } + if err := r.ensureCombinedPinCapacity(ctx, userID, folderID, peers[0]); err != nil { + if errors.Is(err, domain.ErrPinnedDialogsTooMuch) { + return false, pinnedTooMuchErr() + } + return false, internalErr() + } + } changed, folderID, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned) if err != nil { if errors.Is(err, domain.ErrPinnedDialogsTooMuch) { @@ -579,6 +655,11 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages return false, internalErr() } if changed { + if pinned { + if err := r.promoteCombinedPinnedDialog(ctx, userID, folderID, peers[0]); err != nil { + return false, internalErr() + } + } date := int(r.clock.Now().Unix()) var recorded domain.UpdateEvent if r.deps.Updates != nil { @@ -667,12 +748,36 @@ func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.Mes if err != nil { return false, err } - if r.deps.Dialogs == nil { + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, peer := range peers { + if _, duplicate := seen[peer]; duplicate { + return false, peerIDInvalidErr() + } + seen[peer] = struct{}{} + if req.FolderID != domain.DialogMainFolderID && peer.Type == domain.PeerTypeCommunity { + return false, folderIDInvalidErr() + } + } + if len(peers) > domain.PinnedDialogsLimit(req.FolderID, r.userIsPremium(ctx, userID)) { + return false, pinnedTooMuchErr() + } + if r.deps.Dialogs == nil && r.deps.Communities == nil { return true, nil } - changed, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce()) - if err != nil { - return false, internalErr() + changed := false + if r.deps.Dialogs != nil { + dialogsChanged, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce()) + if err != nil { + return false, internalErr() + } + changed = dialogsChanged + } + if r.deps.Communities != nil && req.FolderID == domain.DialogMainFolderID { + communitiesChanged, err := r.deps.Communities.ReorderPinned(ctx, userID, peers, req.GetForce()) + if err != nil { + return false, communityErr(err) + } + changed = changed || communitiesChanged } if !changed { return true, nil @@ -871,6 +976,15 @@ func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items [ hasFolder := false for _, item := range items { switch p := item.(type) { + case *tg.InputDialogPeerCommunity: + view, ok, err := r.communityDialogPeerFromInput(ctx, userID, p) + if err != nil { + return nil, err + } + if !ok { + return nil, inputConstructorInvalidErr() + } + peers = append(peers, domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID}) case *tg.InputDialogPeer: peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer) if err != nil { diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go index 65e34811..5ba0e8e8 100644 --- a/internal/rpc/messages_history.go +++ b/internal/rpc/messages_history.go @@ -528,19 +528,17 @@ func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesG } func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) { - // Layer 228 adds an optional community scope. telesrv has no Communities - // membership/link read model yet, so treating this as an ordinary global - // search would leak results outside the requested scope. Reject it before - // any search/store work until that model exists. - if _, ok := req.GetCommunity(); ok || req.Community != nil { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } if req.BroadcastsOnly && req.GroupsOnly { return &tg.MessagesMessages{}, nil } query := normalizeSearchQuery(req.Q) musicOnly := messagesSearchFilterMusic(req.Filter) - if query == "" && !musicOnly { + communityInput, hasCommunity := req.GetCommunity() + if !hasCommunity && req.Community != nil { + communityInput, hasCommunity = req.Community, true + } + emptyCommunitySearch := query == "" && !musicOnly && hasCommunity && messagesSearchFilterEmpty(req.Filter) + if query == "" && !musicOnly && !emptyCommunitySearch { return nil, searchQueryEmptyErr() } if utf8.RuneCountInString(query) > maxMessageSearchQLength { @@ -553,6 +551,19 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea if err != nil { return nil, internalErr() } + var communityView *domain.CommunityView + var communityScope domain.CommunitySearchScope + if hasCommunity { + view, err := r.communityFromInput(ctx, userID, communityInput) + if err != nil { + return nil, err + } + scope, err := r.deps.Communities.SearchScope(ctx, userID, view.Community.ID) + if err != nil { + return nil, communityErr(err) + } + communityView, communityScope = &view, scope + } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { limit = domain.MaxChannelGlobalSearchLimit @@ -565,6 +576,9 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea if err != nil { return nil, err } + if emptyCommunitySearch { + return appendCommunitySearchChat(&tg.MessagesMessages{}, communityView), nil + } var private domain.MessageList if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil { filter := domain.MessageFilter{ @@ -574,6 +588,10 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea Limit: limit + 1, MusicOnly: musicOnly, } + if communityView != nil { + filter.RestrictPeerIDs = true + filter.PeerIDs = communityScope.BotUserIDs + } if req.MaxDate > 0 { filter.OffsetDate = req.MaxDate } @@ -586,30 +604,49 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea } } if req.UsersOnly || r.deps.Channels == nil { - return tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), nil + return appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView), nil } channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{ - Query: query, - BroadcastsOnly: req.BroadcastsOnly, - GroupsOnly: req.GroupsOnly, - MusicOnly: musicOnly, - HasFolderID: hasFolderID, - FolderID: folderID, - OffsetRate: req.OffsetRate, - OffsetChannelID: channelOffsetID, - OffsetID: req.OffsetID, - MinDate: req.MinDate, - MaxDate: req.MaxDate, - Limit: limit, + Query: query, + ChannelIDs: communityScope.ChannelIDs, + RestrictChannelIDs: communityView != nil, + AllowPublicPreview: communityView != nil, + BroadcastsOnly: req.BroadcastsOnly, + GroupsOnly: req.GroupsOnly, + MusicOnly: musicOnly, + HasFolderID: hasFolderID, + FolderID: folderID, + OffsetRate: req.OffsetRate, + OffsetChannelID: channelOffsetID, + OffsetID: req.OffsetID, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + Limit: limit, }) if err != nil { return nil, channelInvalidErr(err) } channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory) if req.BroadcastsOnly || req.GroupsOnly { - return r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), nil + return appendCommunitySearchChat(r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), communityView), nil } - return r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), nil + return appendCommunitySearchChat(r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), communityView), nil +} + +func appendCommunitySearchChat(result tg.MessagesMessagesClass, view *domain.CommunityView) tg.MessagesMessagesClass { + if result == nil || view == nil { + return result + } + chat := tgCommunityChat(*view) + switch out := result.(type) { + case *tg.MessagesMessages: + out.Chats = appendUniqueTGChats(out.Chats, chat) + case *tg.MessagesMessagesSlice: + out.Chats = appendUniqueTGChats(out.Chats, chat) + case *tg.MessagesChannelMessages: + out.Chats = appendUniqueTGChats(out.Chats, chat) + } + return result } func limitMessageList(list domain.MessageList, limit int) domain.MessageList { @@ -791,6 +828,11 @@ func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool { return ok } +func messagesSearchFilterEmpty(filter tg.MessagesFilterClass) bool { + _, ok := filter.(*tg.InputMessagesFilterEmpty) + return ok +} + func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool { _, ok := filter.(*tg.InputMessagesFilterChatPhotos) return ok diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go index 6e93452c..ebcf66dd 100644 --- a/internal/rpc/messages_history_rpc_test.go +++ b/internal/rpc/messages_history_rpc_test.go @@ -11,24 +11,127 @@ import ( "go.uber.org/zap/zaptest" "strings" appchannels "telesrv/internal/app/channels" + appcommunities "telesrv/internal/app/communities" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" "testing" ) -func TestMessagesSearchGlobalRejectsUnsupportedCommunityScope(t *testing.T) { - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) +func TestMessagesSearchGlobalRestrictsCommunityScope(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "15550000084", FirstName: "Owner"}) + if err != nil { + t.Fatal(err) + } + viewer, err := users.Create(ctx, domain.User{AccessHash: 85, Phone: "15550000085", FirstName: "Viewer"}) + if err != nil { + t.Fatal(err) + } + channels := memory.NewChannelStore() + channelService := appchannels.NewService(channels) + linked, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Linked", Megagroup: true, MemberUserIDs: []int64{viewer.ID}, Date: 100}) + if err != nil { + t.Fatal(err) + } + publicPreview, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Public Preview", Megagroup: true, Date: 101}) + if err != nil { + t.Fatal(err) + } + publicPreview.Channel, err = channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, ChannelID: publicPreview.Channel.ID, Username: "community_public_preview", + }) + if err != nil { + t.Fatal(err) + } + outside, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Outside", Megagroup: true, Date: 101}) + if err != nil { + t.Fatal(err) + } + communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil)) + community, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{ + Title: "Scope", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: linked.Channel.ID}, + Visibility: domain.CommunityPeerVisible, Date: 102, + }) + if err != nil { + t.Fatal(err) + } + if _, err := communityService.TogglePeerLink(ctx, owner.ID, domain.CommunityTogglePeerLinkRequest{ + CommunityID: community.Community.ID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: publicPreview.Channel.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 103, + }); err != nil { + t.Fatal(err) + } + r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channelService, Communities: communityService}, zaptest.NewLogger(t), clock.System) + for i, channel := range []domain.Channel{linked.Channel, publicPreview.Channel, outside.Channel} { + _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Message: "scoped result", RandomID: int64(9000 + i), + }) + if err != nil { + t.Fatalf("send channel %d: %v", channel.ID, err) + } + } req := &tg.MessagesSearchGlobalRequest{ Q: "scoped", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20, } - req.SetCommunity(&tg.InputChannel{ChannelID: 42, AccessHash: 84}) + req.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash}) - if _, err := r.onMessagesSearchGlobal(WithUserID(context.Background(), 1000000001), req); !tgerr.Is(err, "CHANNEL_INVALID") { - t.Fatalf("community-scoped messages.searchGlobal err = %v, want CHANNEL_INVALID", err) + result, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req) + if err != nil { + t.Fatalf("community-scoped messages.searchGlobal: %v", err) + } + response, ok := result.(*tg.MessagesMessages) + if !ok || len(response.Messages) != 2 { + t.Fatalf("community search result = %#v, want joined and public-preview linked messages", result) + } + gotChannels := map[int64]bool{} + for _, item := range response.Messages { + message, ok := item.(*tg.Message) + if !ok { + t.Fatalf("community search message = %#v, want channel message", item) + } + peer, ok := message.PeerID.(*tg.PeerChannel) + if !ok { + t.Fatalf("community search message peer = %#v", message.PeerID) + } + gotChannels[peer.ChannelID] = true + } + if !gotChannels[linked.Channel.ID] || !gotChannels[publicPreview.Channel.ID] || gotChannels[outside.Channel.ID] { + t.Fatalf("community search channels = %+v", gotChannels) + } + + emptyReq := &tg.MessagesSearchGlobalRequest{ + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + } + emptyReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash}) + emptyResult, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), emptyReq) + if err != nil { + t.Fatalf("empty community-scoped messages.searchGlobal: %v", err) + } + emptyResponse, ok := emptyResult.(*tg.MessagesMessages) + if !ok || len(emptyResponse.Messages) != 0 || len(emptyResponse.Chats) != 1 { + t.Fatalf("empty community search result = %#v, want empty messages with validated Community chat", emptyResult) + } + if got, ok := emptyResponse.Chats[0].(*tg.Community); !ok || got.ID != community.Community.ID { + t.Fatalf("empty community search chat = %#v, want Community %d", emptyResponse.Chats[0], community.Community.ID) + } + + badHashReq := &tg.MessagesSearchGlobalRequest{ + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + } + badHashReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash + 1}) + if _, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), badHashReq); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") { + t.Fatalf("empty community search wrong access hash err = %v, want CHANNEL_PRIVATE", err) } } diff --git a/internal/rpc/messages_monoforum.go b/internal/rpc/messages_monoforum.go index c0fb50a6..fabfb9ce 100644 --- a/internal/rpc/messages_monoforum.go +++ b/internal/rpc/messages_monoforum.go @@ -103,8 +103,8 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d }, nil } -// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身(直接投影,管理员 -// 非其成员故不能走可见性受限的 GetChannels)+ 母广播频道(管理员是其成员)。 +// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影, +// 再按 viewer 补母广播频道。订阅者没有 monoforum member row,管理员身份也只来自母频道。 func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass { chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})} if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil { @@ -151,8 +151,8 @@ func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dia return r.tgUsers(found) } -// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否带 monoforum_peer_id(频道私信发送的唯一标志)。 -// 普通发送恒不带,故据此 gate monoforum 分支,普通发送热路径零额外成本。 +// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。 +// 管理员回复必须带目标订阅者;普通订阅者按官方 TDesktop 行为不携带 reply_to,目标由调用者推导。 func monoforumReplyPresent(input tg.InputReplyToClass) bool { switch v := input.(type) { case *tg.InputReplyToMonoForum: @@ -189,46 +189,75 @@ func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToCla return r.domainPeerFromInputPeer(userID, inputPeer) } +func (r *Router) monoforumSavedPeerForSender(userID int64, isAdmin bool, replyTo tg.InputReplyToClass) (domain.Peer, error) { + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if monoforumReplyPresent(replyTo) { + var valid bool + savedPeer, valid = r.monoforumReplyTargetPeer(userID, replyTo) + if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + } else if isAdmin { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + if !isAdmin && savedPeer.ID != userID { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + return savedPeer, nil +} + +// monoforumMessageReplyFromInput separates the sub-dialog selector from the actual message reply. +// InputReplyToMonoForum only selects a subscriber; InputReplyToMessage may carry both the selector +// and a real reply_to_msg_id, so clear flags.5 before reusing the common structural validator. +func (r *Router) monoforumMessageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) { + switch value := input.(type) { + case nil, *tg.InputReplyToMonoForum: + return nil, nil + case *tg.InputReplyToMessage: + if value == nil { + return nil, nil + } + clean := *value + clean.Flags.Unset(5) + clean.MonoforumPeerID = nil + return r.messageReplyFromInput(ctx, userID, peer, &clean) + default: + return r.messageReplyFromInput(ctx, userID, peer, input) + } +} + // sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。 -// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。 -func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest, fingerprint []byte, preflighted bool) (tg.UpdatesClass, error) { +// saved_peer 对订阅者由调用者推导、对管理员来自 reply_to;管理员可写任意订阅者子会话,订阅者只能写自己的。 +func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, mono domain.Channel, isAdmin bool, req domain.SendMonoforumMessageRequest) (tg.UpdatesClass, error) { if r.deps.Channels == nil { return nil, notImplementedErr() } if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { return nil, peerIDInvalidErr() } - mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) - if err != nil { - if errors.Is(err, domain.ErrChannelInvalid) { - // 带 monoforum_peer_id 却不是 monoforum 频道。 - return nil, tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED") - } - return nil, internalErr() - } - savedPeer, ok := r.monoforumReplyTargetPeer(userID, req.ReplyTo) - if !ok || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { + if mono.ID != peer.ID || !mono.Monoforum || req.SavedPeer.Type != domain.PeerTypeUser || req.SavedPeer.ID == 0 { return nil, replyToMonoforumPeerInvalidErr() } - if !isAdmin && savedPeer.ID != userID { + if !isAdmin && req.SavedPeer.ID != userID { // 普通订阅者只能写自己的子会话,不能写他人的。 return nil, replyToMonoforumPeerInvalidErr() } - res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ - MonoforumID: mono.ID, - SenderUserID: userID, - SavedPeer: savedPeer, - RandomID: req.RandomID, - IdempotencyFingerprint: fingerprint, - IdempotencyPreflighted: preflighted, - Message: req.Message, - Entities: domainMessageEntities(req.Entities), - Date: int(r.clock.Now().Unix()), - }) + req.MonoforumID = mono.ID + req.SenderUserID = userID + if req.Date == 0 { + req.Date = int(r.clock.Now().Unix()) + } + res, err := r.deps.Channels.SendMonoforumMessage(ctx, req) if err != nil { return nil, messageSendErr(err) } - return r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res), nil + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, req.ReplyTo) + } + if !res.Duplicate { + r.enqueueMonoforumMessageFanout(ctx, userID, mono, req.SavedPeer, res) + } + return r.monoforumSendUpdates(ctx, userID, mono, req.SavedPeer, res), nil } // monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage @@ -245,6 +274,11 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID} } updates = append(updates, newMsg) + if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID { + updates = append(updates, &tg.UpdateStarsBalance{ + Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance}, + }) + } date := int(r.clock.Now().Unix()) if res.Duplicate && res.ReplayDeleteEvent != nil { if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil { @@ -261,3 +295,18 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do Date: date, } } + +func (r *Router) monoforumDeliveryUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) *tg.Updates { + updates, _ := r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res).(*tg.Updates) + if updates == nil { + return nil + } + filtered := make([]tg.UpdateClass, 0, len(updates.Updates)) + for _, update := range updates.Updates { + if _, randomMapping := update.(*tg.UpdateMessageID); !randomMapping { + filtered = append(filtered, update) + } + } + updates.Updates = filtered + return updates +} diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go index 5a70beb7..fd9057bc 100644 --- a/internal/rpc/messages_monoforum_rpc_test.go +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "strings" "testing" "github.com/iamxvbaba/td/bin" @@ -10,6 +11,7 @@ import ( "go.uber.org/zap/zaptest" appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" @@ -17,7 +19,7 @@ import ( // TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经 // getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史 -// (消息带 saved_peer_id);非管理员被拒。 +// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。 func TestMonoforumSavedDialogsAndHistory(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -98,12 +100,31 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) { if !seenChats[monoID] || !seenChats[created.Channel.ID] { t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID) } - var deniedRaw bin.Buffer - if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&deniedRaw); err != nil { + var subscriberRaw bin.Buffer + if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&subscriberRaw); err != nil { t.Fatalf("encode non-admin getHistory(monoforum): %v", err) } - if _, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &deniedRaw); err == nil { - t.Fatalf("non-admin getHistory(monoforum) = nil err, want denied") + subscriberEnc, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &subscriberRaw) + if err != nil { + t.Fatalf("non-admin getHistory(monoforum): %v", err) + } + subscriberHistory, ok := subscriberEnc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("non-admin getHistory(monoforum) = %T, want *tg.MessagesChannelMessages", subscriberEnc) + } + if len(subscriberHistory.Messages) != 1 { + t.Fatalf("non-admin getHistory(monoforum) = %d msgs, want own sublist message", len(subscriberHistory.Messages)) + } + subscriberMessage, ok := subscriberHistory.Messages[0].(*tg.Message) + if !ok || subscriberMessage.Message != "hello channel" { + t.Fatalf("non-admin history[0] = %#v, want own 'hello channel'", subscriberHistory.Messages[0]) + } + subscriberSavedPeer, ok := subscriberMessage.GetSavedPeerID() + if !ok { + t.Fatalf("non-admin history message missing saved_peer_id") + } + if peer, ok := subscriberSavedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("non-admin history saved_peer_id = %#v, want self %d", subscriberSavedPeer, sub.ID) } // 管理员看私信列表。 @@ -179,9 +200,9 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) { } } -// TestMonoforumSendMessageWritePath 验证写侧:订阅者经 sendMessage(peer=monoforum, -// reply_to=InputReplyToMonoForum{自己}) 发私信;管理员回复到目标订阅者;订阅者不能写他人子会话; -// 普通发送(无 monoforum_peer_id)不受影响。 +// TestMonoforumSendMessageWritePath 验证写侧:订阅者按 TDesktop 实际请求仅以 +// peer=monoforum 发到自己的子会话;管理员必须显式指定目标订阅者;suggested_post 被持久化返回; +// 订阅者不能写他人子会话。 func TestMonoforumSendMessageWritePath(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -196,39 +217,160 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { channelStore := memory.NewChannelStore() channelSvc := appchannels.NewService(channelStore) + dialogSvc := appdialogs.NewService(memory.NewDialogStore(), channelStore) r := New(Config{}, Deps{ Users: appusers.NewService(userStore), Channels: channelSvc, + Dialogs: dialogSvc, }, zaptest.NewLogger(t), clock.System) created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000}) if err != nil { t.Fatalf("create channel: %v", err) } - enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true) + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 10, true) if err != nil { t.Fatalf("enable DM: %v", err) } monoID := enabled.Channel.LinkedMonoforumID - monoInput := &tg.InputPeerChannel{ChannelID: monoID} + mono, err := channelStore.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum: %v", err) + } + monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash} + monoChannelInput := &tg.InputChannel{ChannelID: monoID, AccessHash: mono.AccessHash} - // 订阅者发私信到自己的子会话。 + // 订阅者不是 monoforum 成员,但 TDesktop 打开会话时必须能读取 full channel shell。 + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, sub.ID), monoChannelInput) + if err != nil { + t.Fatalf("subscriber getFullChannel(monoforum): %v", err) + } + if full == nil || full.FullChat == nil { + t.Fatalf("subscriber getFullChannel(monoforum) = %#v, want full chat", full) + } + + // monoforum 永远不能通过 join 变成普通频道成员,否则会生成错误的 joined service message。 + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, sub.ID), monoChannelInput); err == nil || !strings.Contains(err.Error(), "CHANNEL_MONOFORUM_UNSUPPORTED") { + t.Fatalf("subscriber joinChannel(monoforum) err = %v, want CHANNEL_MONOFORUM_UNSUPPORTED", err) + } + + // TDesktop 在发送前保存相同 suggested_post 草稿;它必须可写、可恢复,不能变成 CHANNEL_PRIVATE。 + draftSuggested := tg.SuggestedPost{} + draftSuggested.SetPrice(&tg.StarsAmount{Amount: 10}) + draftSuggested.SetScheduleDate(1_700_100_000) + draftReq := &tg.MessagesSaveDraftRequest{Peer: monoInput, Message: "pending suggested post"} + draftReq.SetSuggestedPost(draftSuggested) + if ok, err := r.onMessagesSaveDraft(WithUserID(ctx, sub.ID), draftReq); err != nil || !ok { + t.Fatalf("subscriber saveDraft(monoforum) = %v, %v; want true, nil", ok, err) + } + storedDraft, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0) + if err != nil || !found { + t.Fatalf("get persisted monoforum draft = %+v, %v, %v; want found", storedDraft, found, err) + } + if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 { + t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft) + } + tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554} + tooLow.SetAllowPaidStars(9) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") { + t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err) + } + + // TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。 subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555} - subReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) + subReq.ClearDraft = true + subReq.SetAllowPaidStars(20) + suggestedInput := tg.SuggestedPost{} + suggestedInput.SetPrice(&tg.StarsAmount{Amount: 10}) + suggestedInput.SetScheduleDate(1_700_100_000) + subReq.SetSuggestedPost(suggestedInput) subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq) if err != nil { t.Fatalf("subscriber sendMessage(monoforum): %v", err) } - if _, ok := subUpd.(*tg.Updates); !ok { + subUpdates, ok := subUpd.(*tg.Updates) + if !ok { t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd) } + var subMessageID int + var subPaidStars, subBalance int64 + for _, update := range subUpdates.Updates { + if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok { + if message, ok := newMessage.Message.(*tg.Message); ok { + subMessageID = message.ID + subPaidStars, _ = message.GetPaidMessageStars() + } + } + if balance, ok := update.(*tg.UpdateStarsBalance); ok { + if amount, ok := balance.Balance.(*tg.StarsAmount); ok { + subBalance = amount.Amount + } + } + } + if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 { + t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates) + } + if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found { + t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err) + } + duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq) + if err != nil { + t.Fatalf("subscriber paid replay: %v", err) + } + duplicateUpdates, ok := duplicateUpd.(*tg.Updates) + if !ok { + t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd) + } + var duplicateBalance int64 + for _, update := range duplicateUpdates.Updates { + if balance, ok := update.(*tg.UpdateStarsBalance); ok { + if amount, ok := balance.Balance.(*tg.StarsAmount); ok { + duplicateBalance = amount.Amount + } + } + } + if duplicateBalance != 990 { + t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance) + } - // 管理员回复到该订阅者的子会话。 + // 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id + // 和 monoforum target,两部分都必须保留。 adminReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556} - adminReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq); err != nil { + adminReply := &tg.InputReplyToMessage{ReplyToMsgID: subMessageID} + adminReply.SetMonoforumPeerID(&tg.InputPeerUser{UserID: sub.ID}) + adminReq.SetReplyTo(adminReply) + adminReq.SetAllowPaidStars(100) + adminUpd, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq) + if err != nil { t.Fatalf("admin reply sendMessage(monoforum): %v", err) } + if updates, ok := adminUpd.(*tg.Updates); ok { + for _, update := range updates.Updates { + if _, balance := update.(*tg.UpdateStarsBalance); balance { + t.Fatalf("admin free reply emitted a balance debit: %#v", updates.Updates) + } + if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok { + if message, ok := newMessage.Message.(*tg.Message); ok { + if stars, paid := message.GetPaidMessageStars(); paid || stars != 0 { + t.Fatalf("admin reply paid_message_stars = %d/%v, want 0/false", stars, paid) + } + } + } + } + } + + // 带媒体的 suggested post 必须走同一 monoforum 子会话,不能退化为普通频道消息或丢 flags。 + mediaSuggested := tg.SuggestedPost{} + mediaSuggested.SetPrice(&tg.StarsAmount{Amount: 15}) + mediaReq := &tg.MessagesSendMediaRequest{ + Peer: monoInput, RandomID: 558, Message: "media suggestion", + Media: &tg.InputMediaContact{PhoneNumber: "+15550003003", FirstName: "Media", LastName: "Contact", Vcard: ""}, + } + mediaReq.SetSuggestedPost(mediaSuggested) + mediaReq.SetAllowPaidStars(15) + if _, err := r.onMessagesSendMedia(WithUserID(ctx, sub.ID), mediaReq); err != nil { + t.Fatalf("subscriber sendMedia(monoforum): %v", err) + } // 订阅者不能写他人(owner)的子会话。 sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557} @@ -237,7 +379,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { t.Fatalf("subscriber writing another's sublist = nil err, want REPLY_TO_MONOFORUM_PEER_INVALID") } - // 经管理员读历史:子会话含两条(订阅者发 + 管理员回复),倒序。 + // 经管理员读历史:子会话含三条(订阅者文本 + 管理员回复 + 订阅者媒体),倒序。 hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}} hreq.SetParentPeer(monoInput) hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq) @@ -248,11 +390,54 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { if !ok { t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres) } - if len(slice.Messages) != 2 { - t.Fatalf("history = %d msgs, want 2 (sub + admin)", len(slice.Messages)) + if len(slice.Messages) != 3 { + t.Fatalf("history = %d msgs, want 3 (sub text + admin + sub media)", len(slice.Messages)) } top, ok := slice.Messages[0].(*tg.Message) - if !ok || top.Message != "admin reply" { - t.Fatalf("history[0] = %#v, want newest 'admin reply'", slice.Messages[0]) + if !ok || top.Message != "media suggestion" { + t.Fatalf("history[0] = %#v, want newest media suggestion", slice.Messages[0]) + } + if _, ok := top.Media.(*tg.MessageMediaContact); !ok { + t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media) + } + if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 { + t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok) + } + topSuggested, ok := top.GetSuggestedPost() + if !ok { + t.Fatalf("media message missing suggested_post") + } + topPrice, ok := topSuggested.GetPrice() + if !ok { + t.Fatalf("media suggested_post missing price") + } + if stars, ok := topPrice.(*tg.StarsAmount); !ok || stars.Amount != 15 { + t.Fatalf("media suggested_post price = %#v, want 15 Stars", topPrice) + } + adminMessage, ok := slice.Messages[1].(*tg.Message) + if !ok || adminMessage.Message != "admin reply" { + t.Fatalf("history[1] = %#v, want admin reply", slice.Messages[1]) + } + if header, ok := adminMessage.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != subMessageID { + t.Fatalf("admin reply header = %#v, want reply_to_msg_id %d", adminMessage.ReplyTo, subMessageID) + } + suggestedMessage, ok := slice.Messages[2].(*tg.Message) + if !ok { + t.Fatalf("history[2] = %T, want *tg.Message", slice.Messages[2]) + } + suggested, ok := suggestedMessage.GetSuggestedPost() + if !ok { + t.Fatalf("subscriber message missing suggested_post") + } + price, ok := suggested.GetPrice() + if !ok { + t.Fatalf("suggested_post missing price") + } + stars, ok := price.(*tg.StarsAmount) + if !ok || stars.Amount != 10 { + t.Fatalf("suggested_post price = %#v, want 10 Stars", price) + } + if scheduleDate, ok := suggested.GetScheduleDate(); !ok || scheduleDate != 1_700_100_000 { + t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok) } } diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 09c63aa8..515d19fc 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -402,7 +402,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) { if err != nil { return nil, err } - if filter.Hash != 0 { + if filter.Hash != 0 && r.deps.Communities == nil { hashCheck, err := r.deps.Dialogs.GetDialogsHash(ctx, userID, filter) if err != nil { return nil, internalErr() @@ -415,6 +415,10 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) { if err != nil { return nil, internalErr() } + list, err = r.withCommunityDialogList(ctx, userID, filter, list) + if err != nil { + return nil, communityErr(err) + } if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) { pinned, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID) if err != nil { @@ -422,7 +426,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) { } list = tdesktop.MergeInitialDialogsWithPinned(list, pinned) } - if filter.Hash != 0 && list.Hash == filter.Hash { + if filter.Hash != 0 && r.deps.Communities == nil && list.Hash == filter.Hash { return &tg.MessagesDialogsNotModified{Count: list.Count}, nil } return r.tgMessagesDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list)), nil @@ -481,14 +485,31 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) { if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil { return nil, err } + regularPeers := make([]domain.Peer, 0, len(domainPeers)) + communityIDs := make([]int64, 0) + for _, peer := range domainPeers { + if peer.Type == domain.PeerTypeCommunity { + communityIDs = append(communityIDs, peer.ID) + } else { + regularPeers = append(regularPeers, peer) + } + } var list domain.DialogList - if len(domainPeers) > 0 && r.deps.Dialogs != nil { + if len(regularPeers) > 0 && r.deps.Dialogs != nil { var err error - list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers) + list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, regularPeers) if err != nil { return nil, internalErr() } } + if len(communityIDs) > 0 && r.deps.Communities != nil { + views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs) + if err != nil { + return nil, communityErr(err) + } + list.Communities = append(list.Communities, views...) + list.Count += len(views) + } st := domain.UpdateState{Date: int(r.clock.Now().Unix())} if r.deps.Updates != nil { var err error diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index d247c2b1..5906cff8 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -3,10 +3,12 @@ package rpc import ( "context" "errors" - "github.com/iamxvbaba/td/tg" "strings" - "telesrv/internal/domain" "unicode/utf8" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" ) func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { @@ -63,12 +65,39 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = internalErr() return nil, sendErr } - // 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带, - // 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。 - if monoforumReplyPresent(req.ReplyTo) { - savedPeer, valid := r.monoforumReplyTargetPeer(userID, req.ReplyTo) - if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { - sendErr = replyToMonoforumPeerInvalidErr() + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + // monoforum 普通用户发送不带 reply_to,saved_peer 必须由服务端推导为自己;管理员回复才必须 + // 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。 + var mono domain.Channel + var monoforum, monoforumAdmin bool + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) + switch { + case err == nil: + monoforum = true + case !errors.Is(err, domain.ErrChannelInvalid): + sendErr = internalErr() + return nil, sendErr + } + } + if hasSuggestedPost && !monoforum { + sendErr = suggestedPostPeerInvalidErr() + return nil, sendErr + } + if monoforum { + suggestedPost, suggestedErr := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if suggestedErr != nil { + sendErr = suggestedErr + return nil, sendErr + } + savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo) + if err != nil { + sendErr = err + return nil, sendErr + } + replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + sendErr = err return nil, sendErr } replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint) @@ -78,6 +107,9 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend } if replay.found { duplicate = true + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil } if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { @@ -89,13 +121,30 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = err return nil, sendErr } - updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, req, idempotencyFingerprint, replay.checked) + updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{ + SavedPeer: savedPeer, + RandomID: req.RandomID, + IdempotencyFingerprint: idempotencyFingerprint, + IdempotencyPreflighted: replay.checked, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + ReplyTo: replyTo, + Silent: req.Silent, + NoForwards: req.Noforwards, + SuggestedPost: suggestedPost, + AllowPaidStars: req.AllowPaidStars, + ClearDraft: req.ClearDraft, + }) if err != nil { sendErr = err return nil, sendErr } return updates, nil } + if req.AllowPaidStars > 0 { + sendErr = paymentUnsupportedErr() + return nil, sendErr + } replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint) if err != nil { sendErr = err @@ -120,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = err return nil, sendErr } - // reply_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。 + // reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force; + // 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。 // 仅在请求携带 markup 时才查 is_bot,避免普通发送多打一次查询。 var replyMarkup *domain.MessageReplyMarkup if req.ReplyMarkup != nil { - replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) if err != nil { sendErr = replyMarkupErr(err) return nil, sendErr } + if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil { + sendErr = err + return nil, sendErr + } } // rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 // Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。 @@ -201,7 +255,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend } func messageSendErr(err error) error { + var paymentRequired *domain.StarsPaymentRequiredError switch { + case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0: + return allowPaymentRequiredErr(paymentRequired.Stars) + case errors.Is(err, domain.ErrStarsInsufficient): + return balanceTooLowErr() case errors.Is(err, domain.ErrUserFrozen): return frozenMethodInvalidErr() case errors.Is(err, domain.ErrReplyMessageIDInvalid): @@ -314,10 +373,8 @@ func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error { // req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。 case req.AllowPaidStars < 0: return starsAmountInvalidErr() - case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + case req.AllowPaidFloodskip: return paymentUnsupportedErr() - case !req.SuggestedPost.Zero(): - return suggestedPostPeerInvalidErr() default: return nil } diff --git a/internal/rpc/messages_suggested_post.go b/internal/rpc/messages_suggested_post.go new file mode 100644 index 00000000..8cb59754 --- /dev/null +++ b/internal/rpc/messages_suggested_post.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +const ( + minSuggestedPostStars int64 = 5 + maxSuggestedPostStars int64 = 100_000 + minSuggestedPostNanoTON int64 = 10_000_000 + maxSuggestedPostNanoTON int64 = 10_000_000_000_000 +) + +func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) { + if !present { + return nil, nil + } + if input.GetAccepted() || input.GetRejected() { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out := &domain.SuggestedPost{} + if date, ok := input.GetScheduleDate(); ok { + if date <= 0 { + return nil, scheduleDateInvalidErr() + } + out.ScheduleDate = date + } + if price, ok := input.GetPrice(); ok { + switch value := price.(type) { + case *tg.StarsAmount: + if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars || + value.Nanos < 0 || value.Nanos >= 1_000_000_000 || value.Amount == maxSuggestedPostStars && value.Nanos != 0 { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: value.Amount, Nanos: value.Nanos} + case *tg.StarsTonAmount: + if value == nil || value.Amount < minSuggestedPostNanoTON || value.Amount > maxSuggestedPostNanoTON { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: value.Amount} + default: + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + } + return out, nil +} + +func tgSuggestedPost(input *domain.SuggestedPost) (tg.SuggestedPost, bool) { + if input == nil { + return tg.SuggestedPost{}, false + } + out := tg.SuggestedPost{} + if input.Accepted { + out.SetAccepted(true) + } + if input.Rejected { + out.SetRejected(true) + } + if input.ScheduleDate > 0 { + out.SetScheduleDate(input.ScheduleDate) + } + if input.Price != nil { + switch input.Price.Kind { + case domain.SuggestedPostPriceStars: + out.SetPrice(&tg.StarsAmount{Amount: input.Price.Amount, Nanos: input.Price.Nanos}) + case domain.SuggestedPostPriceTON: + out.SetPrice(&tg.StarsTonAmount{Amount: input.Price.Amount}) + } + } + return out, true +} diff --git a/internal/rpc/passive_compat_test.go b/internal/rpc/passive_compat_test.go index d75a7f17..92879f2d 100644 --- a/internal/rpc/passive_compat_test.go +++ b/internal/rpc/passive_compat_test.go @@ -213,7 +213,7 @@ func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t if !ok { t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueAdsAccountURL", got) } - if url.URL != "https://ads.telegram.org/" { + if url.URL != "https://telesrv.net" { t.Fatalf("url = %q, want ads compat URL", url.URL) } diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index 378821ee..56c3ff2b 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -33,12 +33,11 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) { return r.onPaymentsGetStarsTransactions(ctx, layerRequest) }) + registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) { + return r.onPaymentsCheckCanSendGift(ctx, req) + }) registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) { - hash := layerRequest. - Hash - _ = hash - - return tdesktop.StarGiftActiveAuctions(), nil + return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest) }) registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) { return r.onPaymentsGetStarGifts(ctx, layerRequest. @@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest. GiftID) }) + registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) { + return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID) + }) registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) { return r.onPaymentsGetUniqueStarGift(ctx, layerRequest. Slug) }) + registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) { + return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req) + }) + registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) { + return r.onPaymentsGetResaleStarGifts(ctx, req) + }) registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) { return r.onPaymentsGetPaymentForm(ctx, layerRequest) }) @@ -75,6 +83,36 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) { return r.onPaymentsUpgradeStarGift(ctx, layerRequest) }) + registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) { + return r.onPaymentsUpdateStarGiftPrice(ctx, req) + }) + registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) { + return r.onPaymentsTransferStarGift(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) { + return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req) + }) + registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) { + return r.onPaymentsSendStarGiftOffer(ctx, req) + }) + registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) { + return r.onPaymentsResolveStarGiftOffer(ctx, req) + }) + registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) { + return r.onPaymentsGetCraftStarGifts(ctx, req) + }) + registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) { + return r.onPaymentsCraftStarGift(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) { + return r.onPaymentsGetStarGiftAuctionState(ctx, req) + }) + registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) { + return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req) + }) + registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) { + return r.onPaymentsToggleChatStarGiftNotifications(ctx, req) + }) registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) { return r.onPaymentsGetStarGiftCollections(ctx, layerRequest) }) @@ -105,39 +143,119 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { return nil, err } - return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil + return &tg.PaymentsStarsRevenueAdsAccountURL{URL: r.publicLink("")}, nil }) registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req == nil { - return nil, peerIDInvalidErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return tdesktop.StarsRevenueStats(req.GetTon()), nil + return r.onPaymentsGetStarsRevenueStats(ctx, req) }) } -// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。 -// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)—— -// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。 -func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { - if req != nil && req.GetTon() { - // TON 余额未建模:返回 0 nanoton 的合法响应。 - return emptyStarsStatus(&tg.StarsTonAmount{}), nil - } - if r.deps.Stars == nil { - return emptyStarsStatus(&tg.StarsAmount{}), nil - } +// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from +// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal +// and bot revenue remain the bounded compatibility response because their +// revenue bucket is distinct from the general Stars balance and is not modeled. +func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) { userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } + if req == nil { + return nil, peerIDInvalidErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + ton := req.GetTon() + if owner.Type != domain.PeerTypeChannel { + return tdesktop.StarsRevenueStats(ton), nil + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil { + return nil, err + } + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + return tdesktop.StarsRevenueStats(ton), nil + } + var balance int64 + if ton { + balance, err = ledger.ChannelTonBalance(ctx, owner.ID) + } else { + balance, err = ledger.ChannelStarsBalance(ctx, owner.ID) + } + if err != nil { + return nil, internalErr() + } + stats := tdesktop.StarsRevenueStats(ton) + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if ton { + amount = &tg.StarsTonAmount{Amount: balance} + } + // Channel ledgers currently only receive collectible conversion/marketplace + // proceeds and have no withdrawal/debit path, so balance equals lifetime + // revenue. Withdrawal stays disabled because no external payout exists. + stats.Status.CurrentBalance = amount + stats.Status.AvailableBalance = amount + stats.Status.OverallRevenue = amount + return stats, nil +} + +type channelGiftLedgerReader interface { + ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) + ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) + ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) + ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) +} + +// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本 +// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。 +// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)—— +// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。 +func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { + userID, owner, err := r.starGiftLedgerOwner(ctx, req) + if err != nil { + return nil, err + } + ton := req != nil && req.GetTon() + if owner.Type == domain.PeerTypeChannel { + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + if ton { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + var balance int64 + if ton { + balance, err = ledger.ChannelTonBalance(ctx, owner.ID) + } else { + balance, err = ledger.ChannelStarsBalance(ctx, owner.ID) + } + if err != nil { + return nil, internalErr() + } + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if ton { + amount = &tg.StarsTonAmount{Amount: balance} + } + out := emptyStarsStatus(amount) + out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID}) + return out, nil + } + if ton { + if r.deps.Gifts == nil { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + balance, err := r.deps.Gifts.TonBalance(ctx, userID) + if err != nil { + return nil, internalErr() + } + return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } bal, err := r.deps.Stars.GetBalance(ctx, userID) if err != nil { return nil, starsErr(err) @@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG // onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。 // 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。 func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) { - if req != nil && req.GetTon() { - return emptyStarsStatus(&tg.StarsTonAmount{}), nil - } - if r.deps.Stars == nil { - return emptyStarsStatus(&tg.StarsAmount{}), nil - } - userID, _, err := r.currentUserID(ctx) + userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req) if err != nil { - return nil, internalErr() + return nil, err } - offset := "" - limit := domain.MaxStarsTransactionsLimit + offset, limit := "", domain.MaxStarsTransactionsLimit if req != nil { offset = req.Offset if req.Limit > 0 { limit = req.Limit } } + ton := req != nil && req.GetTon() + if owner.Type == domain.PeerTypeChannel { + ledger, ok := r.deps.Gifts.(channelGiftLedgerReader) + if !ok { + if ton { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + if ton { + page, err := ledger.ChannelTonTransactions(ctx, owner.ID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance}) + if txns := tgTonTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out) + return out, nil + } + page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance}) + if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out) + return out, nil + } + if ton { + if r.deps.Gifts == nil { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + page, err := r.deps.Gifts.TonTransactions(ctx, userID, offset, limit) + if err != nil { + return nil, internalErr() + } + out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance}) + if txns := tgTonTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + ids := make([]int64, 0) + for _, txn := range page.Transactions { + if txn.Peer.Type == domain.PeerTypeUser { + ids = append(ids, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids))) + return out, nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit) if err != nil { return nil, starsErr(err) @@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay return out, nil } +func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) { + if req == nil { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return r.starGiftLedgerOwnerForPeer(ctx, req.Peer) +} + +func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) { + if req == nil { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return r.starGiftLedgerOwnerForPeer(ctx, req.Peer) +} + +func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.Peer{}, internalErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return 0, domain.Peer{}, err + } + if owner.Type == domain.PeerTypeUser { + if owner.ID != userID { + return 0, domain.Peer{}, peerIDInvalidErr() + } + return userID, owner, nil + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil { + return 0, domain.Peer{}, err + } + return userID, owner, nil +} + +func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) { + userIDs := make([]int64, 0, len(txns)) + channelIDs := []int64{ownerChannelID} + for _, txn := range txns { + switch txn.Peer.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, txn.Peer.ID) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) +} + +func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) { + userIDs := make([]int64, 0, len(txns)) + channelIDs := []int64{ownerChannelID} + for _, txn := range txns { + switch txn.Peer.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, txn.Peer.ID) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, txn.Peer.ID) + } + } + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) +} + // emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。 func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus { return &tg.PaymentsStarsStatus{ @@ -212,8 +453,53 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction { switch t.Reason { case domain.StarsReasonReaction: item.Reaction = true + case domain.StarsReasonPaidMessage: + item.SetPaidMessages(1) case domain.StarsReasonGift: item.Gift = true + case domain.StarsReasonGiftUpgrade: + item.StargiftUpgrade = true + case domain.StarsReasonGiftResale: + item.StargiftResale = true + case domain.StarsReasonGiftPrepaid: + item.StargiftPrepaidUpgrade = true + case domain.StarsReasonGiftDrop: + item.StargiftDropOriginalDetails = true + case domain.StarsReasonGiftAuction: + item.StargiftAuctionBid = true + case domain.StarsReasonGiftOffer: + item.Offer = true + } + out = append(out, item) + } + return out +} + +func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction { + out := make([]tg.StarsTransaction, 0, len(in)) + for _, t := range in { + item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount}, + Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})} + if t.Amount > 0 { + item.Refund = true + } + if t.Title != "" { + item.SetTitle(t.Title) + } + if t.Description != "" { + item.SetDescription(t.Description) + } + switch t.Reason { + case domain.StarsReasonGiftResale: + item.StargiftResale = true + case domain.StarsReasonGiftOffer: + item.Offer = true + case domain.StarsReasonGiftAuction: + item.StargiftAuctionBid = true + case domain.StarsReasonGiftPrepaid: + item.StargiftPrepaidUpgrade = true + case domain.StarsReasonGiftDrop: + item.StargiftDropOriginalDetails = true } out = append(out, item) } diff --git a/internal/rpc/payments_star_gift_catalog_projection_test.go b/internal/rpc/payments_star_gift_catalog_projection_test.go new file mode 100644 index 00000000..eb241e93 --- /dev/null +++ b/internal/rpc/payments_star_gift_catalog_projection_test.go @@ -0,0 +1,98 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" + + "telesrv/internal/domain" +) + +func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) { + base := domain.StarGift{ + ID: 8001, + RevisionID: 9001, + Stars: 100, + ConvertStars: 85, + Title: "Fresh Socks", + FirstSaleDate: 100, + LastSaleDate: 200, + Sticker: domain.Document{ + ID: 700, + AccessHash: 7, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}, + }, + } + + tests := []struct { + name string + gift domain.StarGift + wantSoldOut bool + wantSaleDate bool + }{ + {name: "unlimited live gift with operational sale history", gift: base}, + {name: "limited live gift", gift: func() domain.StarGift { + gift := base + gift.Limited = true + gift.AvailabilityRemains = 9 + gift.AvailabilityTotal = 10 + return gift + }()}, + {name: "sold out gift", gift: func() domain.StarGift { + gift := base + gift.Limited = true + gift.SoldOut = true + gift.AvailabilityTotal = 10 + return gift + }(), wantSoldOut: true, wantSaleDate: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, profile := range []tlprofile.Profile{ + tlprofile.Profile225, + tlprofile.Profile226, + tlprofile.Profile227, + tlprofile.Profile228, + } { + response := &tg.PaymentsStarGifts{ + Hash: 1, + Gifts: []tg.StarGiftClass{tgStarGift(test.gift)}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, response, wire); err != nil { + t.Fatalf("encode Layer %d catalog: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d catalog: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.PaymentsStarGifts) + if !ok || len(decoded.Gifts) != 1 { + t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject) + } + gift, ok := decoded.Gifts[0].(*tg.StarGift) + if !ok { + t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0]) + } + if gift.SoldOut != test.wantSoldOut { + t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut) + } + first, firstSet := gift.GetFirstSaleDate() + last, lastSet := gift.GetLastSaleDate() + if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate { + t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate) + } + if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) { + t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate) + } + } + }) + } +} diff --git a/internal/rpc/payments_star_gift_lifecycle.go b/internal/rpc/payments_star_gift_lifecycle.go new file mode 100644 index 00000000..904eae74 --- /dev/null +++ b/internal/rpc/payments_star_gift_lifecycle.go @@ -0,0 +1,1019 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "hash/fnv" + "strings" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + + "telesrv/internal/domain" +) + +func (r *Router) starGiftTransferPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftTransfer) (tg.PaymentsPaymentFormClass, error) { + target, to, err := r.starGiftPaidTransferTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + return &tg.PaymentsPaymentFormStarGift{FormID: starGiftLifecycleFormID("transfer", userID, + target.ID, target.Owner.Type, target.Owner.ID, to.Type, to.ID, target.TransferStars, target.CanTransferAt), + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Collectible gift transfer", Amount: target.TransferStars}}}}, nil +} + +func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftTransfer) (tg.PaymentsPaymentResultClass, error) { + target, to, err := r.starGiftPaidTransferTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("transfer", userID, + target.ID, target.Owner.Type, target.Owner.ID, to.Type, to.ID, target.TransferStars, target.CanTransferAt) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, + Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to, + ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID), + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(target.Owner) + r.invalidateStarGiftOwner(to) + return &tg.PaymentsPaymentResult{Updates: r.starGiftTransferUpdates(ctx, userID, result, false)}, nil +} + +func (r *Router) starGiftPaidTransferTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftTransfer) (domain.SavedStarGift, domain.Peer, error) { + if inv == nil || r.deps.Gifts == nil { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if err != nil || !ok { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, domain.Peer{}, err + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.ToID) + if err != nil { + return domain.SavedStarGift{}, domain.Peer{}, err + } + saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return domain.SavedStarGift{}, domain.Peer{}, internalErr() + } + if !found || saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.TransferStars <= 0 || saved.Owner == to { + return domain.SavedStarGift{}, domain.Peer{}, starGiftInvalidErr() + } + return saved, to, nil +} + +func (r *Router) starGiftResalePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftResale) (tg.PaymentsPaymentFormClass, error) { + gift, to, amount, err := r.starGiftResaleTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + currency := string(amount.Currency) + return &tg.PaymentsPaymentFormStarGift{FormID: starGiftLifecycleFormID("resale", userID, + gift.ID, gift.Owner.Type, gift.Owner.ID, to.Type, to.ID, amount.Currency, amount.Amount, gift.ResellVersion), + Invoice: tg.Invoice{Currency: currency, Prices: []tg.LabeledPrice{{Label: "Collectible gift resale", Amount: amount.Amount}}}}, nil +} + +func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftResale) (tg.PaymentsPaymentResultClass, error) { + gift, to, amount, err := r.starGiftResaleTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("resale", userID, + gift.ID, gift.Owner.Type, gift.Owner.ID, to.Type, to.ID, amount.Currency, amount.Amount, gift.ResellVersion) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := r.deps.Gifts.TonBalance(ctx, userID); err != nil { + return nil, internalErr() + } + } else if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID, + Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID), + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(gift.Owner) + r.invalidateStarGiftOwner(to) + return &tg.PaymentsPaymentResult{Updates: r.starGiftTransferUpdates(ctx, userID, result, amount.Currency == domain.StarGiftCurrencyTON)}, nil +} + +func (r *Router) starGiftResaleTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftResale) (domain.UniqueStarGift, domain.Peer, domain.StarGiftAmount, error) { + if inv == nil || r.deps.Gifts == nil || strings.TrimSpace(inv.Slug) == "" { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + gift, found, err := r.deps.Gifts.UniqueBySlug(ctx, inv.Slug) + if err != nil { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, internalErr() + } + if !found || gift.ResellAmount == nil || gift.Burned || gift.OwnerAddress != "" { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.ToID) + if err != nil { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, err + } + wantCurrency := domain.StarGiftCurrencyStars + if inv.Ton { + wantCurrency = domain.StarGiftCurrencyTON + } + if gift.ResellAmount.Currency != wantCurrency || gift.Owner == to { + return domain.UniqueStarGift{}, domain.Peer{}, domain.StarGiftAmount{}, starGiftInvalidErr() + } + return gift, to, *gift.ResellAmount, nil +} + +func (r *Router) starGiftAuctionBidPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (tg.PaymentsPaymentFormClass, error) { + state, peer, delta, err := r.starGiftAuctionBidTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + return &tg.PaymentsPaymentFormStars{FormID: starGiftLifecycleFormID("auction", userID, + state.Gift.ID, peer.Type, peer.ID, inv.BidAmount, state.Version), + BotID: domain.OfficialSystemUserID, Title: state.Gift.Title, Description: "Collectible gift auction bid", + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Auction bid", Amount: delta}}}, + Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()})}, nil +} + +func (r *Router) sendStarGiftAuctionBidForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (tg.PaymentsPaymentResultClass, error) { + state, peer, _, err := r.starGiftAuctionBidTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + wantFormID := starGiftLifecycleFormID("auction", userID, + state.Gift.ID, peer.Type, peer.ID, inv.BidAmount, state.Version) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + message := "" + if text, ok := inv.GetMessage(); ok { + message = clampGiftMessage(text.Text) + } + newState, balance, err := r.deps.Gifts.BidAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: userID, + GiftID: inv.GiftID, Peer: peer, BidAmount: inv.BidAmount, HideName: inv.HideName, Message: message, + UpdateBid: inv.UpdateBid, FormID: formID, Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := emptyGiftUpdates(r.clock.Now().Unix()) + updates.Updates = append(updates.Updates, + &tg.UpdateStarGiftAuctionState{GiftID: inv.GiftID, State: tgStarGiftAuctionState(newState)}, + &tg.UpdateStarGiftAuctionUserState{GiftID: inv.GiftID, UserState: tgStarGiftAuctionUserState(newState.UserState)}) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftAuctionBidTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftAuctionBid) (domain.StarGiftAuction, domain.Peer, int64, error) { + if inv == nil || r.deps.Gifts == nil || inv.GiftID <= 0 || inv.BidAmount <= 0 { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + state, err := r.deps.Gifts.AuctionState(ctx, userID, inv.GiftID, "", int(r.clock.Now().Unix())) + if err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftLifecycleErr(err) + } + oldAmount := state.UserState.BidAmount + peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if inv.UpdateBid { + if oldAmount <= 0 || inv.HideName { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if _, ok := inv.GetPeer(); ok { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if _, ok := inv.GetMessage(); ok { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + peer = state.UserState.BidPeer + } else { + if oldAmount > 0 { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + if inputPeer, ok := inv.GetPeer(); ok { + peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) + if err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, err + } + } + } + if peer.Type == domain.PeerTypeChannel { + if err := r.checkStarGiftOwnerPermission(ctx, userID, peer); err != nil { + return domain.StarGiftAuction{}, domain.Peer{}, 0, err + } + } + minimum := state.MinBidAmount + if oldAmount > 0 { + minimum = state.UserState.MinBidAmount + } + if inv.BidAmount < minimum || inv.BidAmount <= oldAmount { + return domain.StarGiftAuction{}, domain.Peer{}, 0, starGiftInvalidErr() + } + return state, peer, inv.BidAmount - oldAmount, nil +} + +func (r *Router) starGiftPrepaidUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (tg.PaymentsPaymentFormClass, error) { + owner, target, price, err := r.starGiftPrepaidUpgradeTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + formID := starGiftLifecycleFormID("prepay-upgrade", userID, owner.Type, owner.ID, target.ID, inv.Hash, price) + return &tg.PaymentsPaymentFormStarGift{FormID: formID, + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Prepaid collectible gift upgrade", Amount: price}}}}, nil +} + +func (r *Router) sendStarGiftPrepaidUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (tg.PaymentsPaymentResultClass, error) { + if inv == nil || r.deps.Gifts == nil || formID == 0 { + return nil, starGiftInvalidErr() + } + owner, target, price, targetErr := r.starGiftPrepaidUpgradeTarget(ctx, userID, inv) + commandKey := fmt.Sprintf("prepay-upgrade:%s:%d", strings.TrimSpace(inv.Hash), formID) + if targetErr == nil { + if formID != starGiftLifecycleFormID("prepay-upgrade", userID, owner.Type, owner.ID, target.ID, inv.Hash, price) { + return nil, starsFormAmountMismatchErr() + } + } else { + var err error + owner, err = r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) + if err != nil { + return nil, err + } + price = 0 // accepted only by the store's exact replay path + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.PrepayUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{PayerUserID: userID, + Owner: owner, Hash: strings.TrimSpace(inv.Hash), ChargeStars: price, FormID: formID, CommandKey: commandKey, + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(owner) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftPrepaidUpgradeTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftPrepaidUpgrade) (domain.Peer, domain.SavedStarGift, int64, error) { + if inv == nil || r.deps.Gifts == nil || strings.TrimSpace(inv.Hash) == "" { + return domain.Peer{}, domain.SavedStarGift{}, 0, starGiftInvalidErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) + if err != nil { + return domain.Peer{}, domain.SavedStarGift{}, 0, err + } + target, price, err := r.deps.Gifts.PrepaidUpgradeTarget(ctx, owner, strings.TrimSpace(inv.Hash)) + if err != nil { + return domain.Peer{}, domain.SavedStarGift{}, 0, starGiftLifecycleErr(err) + } + return owner, target, price, nil +} + +func (r *Router) starGiftDropDetailsPaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (tg.PaymentsPaymentFormClass, error) { + target, err := r.starGiftDropDetailsTarget(ctx, userID, inv) + if err != nil { + return nil, err + } + formID := starGiftLifecycleFormID("drop-details", userID, target.ID, target.UniqueGiftID, target.DropOriginalDetailsStars) + return &tg.PaymentsPaymentFormStarGift{FormID: formID, + Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: "Remove collectible gift original details", Amount: target.DropOriginalDetailsStars}}}}, nil +} + +func (r *Router) sendStarGiftDropDetailsForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (tg.PaymentsPaymentResultClass, error) { + if inv == nil || r.deps.Gifts == nil || formID == 0 { + return nil, starGiftInvalidErr() + } + ref, ok, refErr := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if refErr != nil || !ok { + return nil, starGiftInvalidErr() + } + target, targetErr := r.starGiftDropDetailsTarget(ctx, userID, inv) + charge := int64(0) + if targetErr == nil { + charge = target.DropOriginalDetailsStars + if formID != starGiftLifecycleFormID("drop-details", userID, target.ID, target.UniqueGiftID, charge) { + return nil, starsFormAmountMismatchErr() + } + } + if r.deps.Stars != nil { + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + } + result, err := r.deps.Gifts.DropOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{UserID: userID, + Ref: ref, ChargeStars: charge, FormID: formID, CommandKey: fmt.Sprintf("drop-details:%s:%s:%d", ref.Owner.Type, starGiftRefValue(ref), formID), + Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := emptyGiftUpdates(r.clock.Now().Unix()) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(ref.Owner) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) starGiftDropDetailsTarget(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftDropOriginalDetails) (domain.SavedStarGift, error) { + if inv == nil || r.deps.Gifts == nil { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, inv.Stargift) + if err != nil || !ok { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, err + } + target, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return domain.SavedStarGift{}, internalErr() + } + if !found || !target.LifecycleStatus.Live() || target.UniqueGiftID <= 0 || target.DropOriginalDetailsStars <= 0 { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + return target, nil +} + +func starGiftLifecycleFormID(kind string, values ...any) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte("telesrv:star-gift:" + kind + ":v1")) + for _, value := range values { + _, _ = fmt.Fprintf(h, ":%v", value) + } + id := int64(h.Sum64() & 0x7fffffffffffffff) + if id == 0 { + return 1 + } + return id +} + +func (r *Router) onPaymentsCheckCanSendGift(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (tg.PaymentsCheckCanSendGiftResultClass, error) { + if req == nil || req.GiftID <= 0 || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + gift, found, err := r.deps.Gifts.GiftByID(ctx, req.GiftID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + now := int(r.clock.Now().Unix()) + switch { + case gift.SoldOut || gift.Limited && gift.AvailabilityRemains <= 0: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is sold out."}}, nil + case gift.LockedUntilDate > now: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is not available yet."}}, nil + case gift.Auction: + return &tg.PaymentsCheckCanSendGiftResultFail{Reason: tg.TextWithEntities{Text: "This gift is distributed through an auction."}}, nil + default: + return &tg.PaymentsCheckCanSendGiftResultOk{}, nil + } +} + +func (r *Router) onPaymentsGetUniqueStarGiftValueInfo(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (*tg.PaymentsUniqueStarGiftValueInfo, error) { + if req == nil || strings.TrimSpace(req.Slug) == "" || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, req.Slug) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + info, err := r.deps.Gifts.ValueInfo(ctx, unique.ID) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsUniqueStarGiftValueInfo{Currency: info.Currency, Value: info.Value, + InitialSaleDate: info.InitialSaleDate, InitialSaleStars: info.InitialSaleStars, + InitialSalePrice: info.InitialSalePrice} + if info.ValueIsAverage { + out.SetValueIsAverage(true) + } + if info.LastSaleDate > 0 { + out.SetLastSaleDate(info.LastSaleDate) + out.SetLastSalePrice(info.LastSalePrice) + } + if info.FloorPrice > 0 { + out.SetFloorPrice(info.FloorPrice) + } + if info.AveragePrice > 0 { + out.SetAveragePrice(info.AveragePrice) + } + out.SetListedCount(info.ListedCount) + return out, nil +} + +func (r *Router) onPaymentsGetResaleStarGifts(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (*tg.PaymentsResaleStarGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + filter := domain.StarGiftResaleFilter{GiftID: req.GiftID, SortByPrice: req.SortByPrice, SortByNum: req.SortByNum, + ForCraft: req.ForCraft, StarsOnly: req.StarsOnly, Offset: req.Offset, Limit: req.Limit} + if filter.Limit <= 0 { + filter.Limit = domain.MaxSavedStarGiftsLimit + } + if attributes, ok := req.GetAttributes(); ok { + for _, attribute := range attributes { + switch value := attribute.(type) { + case *tg.StarGiftAttributeIDModel: + if value != nil && value.DocumentID > 0 { + filter.ModelIDs = append(filter.ModelIDs, value.DocumentID) + } + case *tg.StarGiftAttributeIDPattern: + if value != nil && value.DocumentID > 0 { + filter.PatternIDs = append(filter.PatternIDs, value.DocumentID) + } + case *tg.StarGiftAttributeIDBackdrop: + if value != nil && value.BackdropID > 0 { + filter.BackdropIDs = append(filter.BackdropIDs, int64(value.BackdropID)) + } + default: + return nil, starGiftInvalidErr() + } + } + } + page, err := r.deps.Gifts.ListResale(ctx, filter) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsResaleStarGifts{Count: page.Count, Gifts: make([]tg.StarGiftClass, 0, len(page.Gifts)), + Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs, channelIDs := make([]int64, 0), make([]int64, 0) + for _, gift := range page.Gifts { + out.Gifts = append(out.Gifts, tgUniqueStarGift(gift)) + if gift.Owner.Type == domain.PeerTypeUser { + userIDs = append(userIDs, gift.Owner.ID) + } else if gift.Owner.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, gift.Owner.ID) + } + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + viewerID, _, _ := r.currentUserID(ctx) + out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs)) + if attributesHash, requested := req.GetAttributesHash(); requested { + preview, found, previewErr := r.deps.Gifts.CollectiblePreview(ctx, req.GiftID) + if previewErr != nil { + return nil, internalErr() + } + if found { + hash := int64(preview.Revision) + out.SetAttributesHash(hash) + if attributesHash != hash { + attributes := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) + for _, attribute := range preview.Models { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + for _, attribute := range preview.Patterns { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + for _, attribute := range preview.Backdrops { + attributes = append(attributes, tgStarGiftAttribute(attribute)) + } + out.SetAttributes(attributes) + } + } + } + return out, nil +} + +func (r *Router) onPaymentsUpdateStarGiftPrice(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok { + return nil, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return nil, err + } + var amount *domain.StarGiftAmount + switch value := req.ResellAmount.(type) { + case *tg.StarsAmount: + if value == nil || value.Amount < 0 || value.Nanos != 0 { + return nil, starGiftInvalidErr() + } + if value.Amount > 0 { + amount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount} + } + case *tg.StarsTonAmount: + if value == nil || value.Amount < 0 { + return nil, starGiftInvalidErr() + } + if value.Amount > 0 { + amount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount} + } + default: + return nil, starGiftInvalidErr() + } + if _, err := r.deps.Gifts.SetListing(ctx, domain.StarGiftListingRequest{ActorUserID: userID, Ref: ref, + Amount: amount, Date: int(r.clock.Now().Unix())}); err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(ref.Owner) + return emptyGiftUpdates(r.clock.Now().Unix()), nil +} + +func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok { + return nil, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return nil, err + } + to, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToID) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to, + CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID), + Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateStarGiftOwner(ref.Owner) + r.invalidateStarGiftOwner(to) + return r.starGiftTransferUpdates(ctx, userID, result, false), nil +} + +func (r *Router) onPaymentsGetStarGiftWithdrawalURL(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (*tg.PaymentsStarGiftWithdrawalURL, error) { + if req == nil || r.deps.Gifts == nil || r.deps.Account == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil { + return nil, passwordErr(err) + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil || !ok || ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil, starGiftInvalidErr() + } + withdrawal, err := r.deps.Gifts.Withdraw(ctx, domain.StarGiftWithdrawalRequest{UserID: userID, Ref: ref, Date: int(r.clock.Now().Unix())}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + return &tg.PaymentsStarGiftWithdrawalURL{URL: withdrawal.URL}, nil +} + +func (r *Router) onPaymentsSendStarGiftOffer(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + price, ok := domainStarGiftAmount(req.Price) + if !ok { + return nil, starGiftInvalidErr() + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.SendOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: userID, Owner: owner, + Slug: req.Slug, Price: price, Duration: req.Duration, RandomID: req.RandomID, Date: now, + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, price.Currency, result.Balance.Balance) + return updates, nil +} + +func (r *Router) onPaymentsResolveStarGiftOffer(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + now := int(r.clock.Now().Unix()) + result, err := r.deps.Gifts.ResolveOffer(ctx, domain.StarGiftResolveOfferRequest{OwnerUserID: userID, + OfferMsgID: req.OfferMsgID, Decline: req.Decline, Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), + OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + if !req.Decline { + if result.Offer.Price.Currency == domain.StarGiftCurrencyTON { + balance, _ := r.deps.Gifts.TonBalance(ctx, userID) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyTON, balance) + } else if r.deps.Stars != nil { + balance, balanceErr := r.deps.Stars.GetBalance(ctx, userID) + if balanceErr == nil { + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) + } + } + } + return updates, nil +} + +func (r *Router) onPaymentsGetCraftStarGifts(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + page, err := r.deps.Gifts.ListCraft(ctx, userID, req.GiftID, req.Offset, req.Limit) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + return r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset) +} + +func (r *Router) onPaymentsCraftStarGift(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (tg.UpdatesClass, error) { + if req == nil || r.deps.Gifts == nil || len(req.Stargift) < 1 || len(req.Stargift) > 4 { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + refs := make([]domain.SavedStarGiftRef, 0, len(req.Stargift)) + commandParts := make([]string, 0, len(req.Stargift)) + seenSavedIDs := make(map[int64]struct{}, len(req.Stargift)) + for _, input := range req.Stargift { + ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + if err != nil || !ok || ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil, starGiftInvalidErr() + } + saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) + if err != nil { + return nil, internalErr() + } + if !found || saved.ID <= 0 || saved.Owner != ref.Owner { + return nil, starGiftInvalidErr() + } + if _, duplicate := seenSavedIDs[saved.ID]; duplicate { + return nil, starGiftInvalidErr() + } + seenSavedIDs[saved.ID] = struct{}{} + refs = append(refs, ref) + // Official wire identities (user msg id, channel saved id or collectible + // slug) identify one durable aggregate and therefore one idempotency key. + commandParts = append(commandParts, fmt.Sprint(saved.ID)) + } + result, err := r.deps.Gifts.Craft(ctx, domain.StarGiftCraftRequest{UserID: userID, Refs: refs, + CommandKey: "rpc:" + strings.Join(commandParts, ","), Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + r.invalidateRPCProjectionForUser(userID) + updates := emptyGiftUpdates(r.clock.Now().Unix()) + if result.Success { + updates = r.starGiftSendUpdates(ctx, userID, result.Send) + } + sourceUpdates := make([]tg.UpdateClass, 0, len(result.SourceEdits)) + for _, edit := range result.SourceEdits { + if edit.UserID != userID { + continue + } + if update := tgOtherUpdateFromEvent(edit.Event); update != nil { + sourceUpdates = append(sourceUpdates, update) + updates.Users = append(updates.Users, r.usersForMessageUpdate(ctx, userID, edit.Message)...) + updates.Chats = append(updates.Chats, r.chatsForMessageUpdate(ctx, userID, edit.Message)...) + if edit.Event.Date > updates.Date { + updates.Date = edit.Event.Date + } + } + } + // Craft source edits reserve pts before the crafted output message, so keep + // them first in the immediate response as well. Other sessions receive the + // same durable edit events through outbox/difference. + updates.Updates = append(sourceUpdates, updates.Updates...) + if !result.Success { + updates.Updates = append(updates.Updates, &tg.UpdateStarGiftCraftFail{}) + } + return updates, nil +} + +func (r *Router) onPaymentsGetStarGiftAuctionState(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (*tg.PaymentsStarGiftAuctionState, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + var giftID int64 + var slug string + switch value := req.Auction.(type) { + case *tg.InputStarGiftAuction: + if value != nil { + giftID = value.GiftID + } + case *tg.InputStarGiftAuctionSlug: + if value != nil { + slug = value.Slug + } + default: + return nil, starGiftInvalidErr() + } + state, err := r.deps.Gifts.AuctionState(ctx, userID, giftID, slug, int(r.clock.Now().Unix())) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + stateClass := tgStarGiftAuctionState(state) + if !state.Finished && req.Version == state.Version { + stateClass = &tg.StarGiftAuctionStateNotModified{} + } + return &tg.PaymentsStarGiftAuctionState{Gift: tgStarGift(state.Gift), State: stateClass, + UserState: tgStarGiftAuctionUserState(state.UserState), Timeout: 30, Users: r.auctionUsers(ctx, userID, state), Chats: []tg.ChatClass{}}, nil +} + +func (r *Router) onPaymentsGetStarGiftActiveAuctions(ctx context.Context, req *tg.PaymentsGetStarGiftActiveAuctionsRequest) (tg.PaymentsStarGiftActiveAuctionsClass, error) { + if req == nil { + return nil, starGiftInvalidErr() + } + if r.deps.Gifts == nil { + return &tg.PaymentsStarGiftActiveAuctions{Auctions: []tg.StarGiftActiveAuctionState{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + states, err := r.deps.Gifts.ActiveAuctions(ctx, userID, int(r.clock.Now().Unix())) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsStarGiftActiveAuctions{Auctions: make([]tg.StarGiftActiveAuctionState, 0, len(states)), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs := make([]int64, 0) + for _, state := range states { + out.Auctions = append(out.Auctions, tg.StarGiftActiveAuctionState{Gift: tgStarGift(state.Gift), + State: tgStarGiftAuctionState(state), UserState: tgStarGiftAuctionUserState(state.UserState)}) + userIDs = append(userIDs, state.TopBidders...) + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(userIDs))) + return out, nil +} + +func (r *Router) onPaymentsGetStarGiftAuctionAcquiredGifts(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (*tg.PaymentsStarGiftAuctionAcquiredGifts, error) { + if req == nil || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + items, err := r.deps.Gifts.AuctionAcquired(ctx, userID, req.GiftID) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + out := &tg.PaymentsStarGiftAuctionAcquiredGifts{Gifts: make([]tg.StarGiftAuctionAcquiredGift, 0, len(items)), + Users: []tg.UserClass{}, Chats: []tg.ChatClass{}} + userIDs, channelIDs := make([]int64, 0), make([]int64, 0) + for _, item := range items { + gift := tg.StarGiftAuctionAcquiredGift{NameHidden: item.NameHidden, Peer: tgPeer(item.Peer), Date: item.Date, + BidAmount: item.BidAmount, Round: item.Round, Pos: item.Pos} + if item.Message != "" { + gift.SetMessage(tg.TextWithEntities{Text: item.Message}) + } + if item.GiftNum > 0 { + gift.SetGiftNum(item.GiftNum) + } + out.Gifts = append(out.Gifts, gift) + if item.Peer.Type == domain.PeerTypeUser { + userIDs = append(userIDs, item.Peer.ID) + } else { + channelIDs = append(channelIDs, item.Peer.ID) + } + } + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(userIDs))) + out.Chats = r.tgChatsForChannelIDs(ctx, userID, uniqueInt64(channelIDs)) + return out, nil +} + +func (r *Router) onPaymentsToggleChatStarGiftNotifications(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (bool, error) { + if req == nil || r.deps.Gifts == nil || r.deps.Channels == nil { + return false, starGiftInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil || peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, peer); err != nil { + return false, err + } + if err := r.deps.Gifts.SetNotifications(ctx, userID, peer.ID, req.Enabled); err != nil { + return false, starGiftLifecycleErr(err) + } + return true, nil +} + +func tgStarGiftAuctionState(state domain.StarGiftAuction) tg.StarGiftAuctionStateClass { + if state.Finished { + out := &tg.StarGiftAuctionStateFinished{StartDate: state.StartDate, EndDate: state.EndDate, AveragePrice: state.AveragePrice} + if state.ListedCount > 0 { + out.SetListedCount(state.ListedCount) + } + return out + } + levels := make([]tg.AuctionBidLevel, 0, len(state.BidLevels)) + for _, level := range state.BidLevels { + levels = append(levels, tg.AuctionBidLevel{Pos: level.Pos, Amount: level.Amount, Date: level.Date}) + } + return &tg.StarGiftAuctionState{Version: state.Version, StartDate: state.StartDate, EndDate: state.EndDate, + MinBidAmount: state.MinBidAmount, BidLevels: levels, TopBidders: state.TopBidders, + NextRoundAt: state.NextRoundAt, LastGiftNum: state.LastGiftNum, GiftsLeft: state.GiftsLeft, + CurrentRound: state.CurrentRound, TotalRounds: state.TotalRounds, + Rounds: []tg.StarGiftAuctionRoundClass{&tg.StarGiftAuctionRound{Num: 1, Duration: state.RoundDuration}}} +} + +func tgStarGiftAuctionUserState(state domain.StarGiftAuctionUserState) tg.StarGiftAuctionUserState { + out := tg.StarGiftAuctionUserState{AcquiredCount: state.AcquiredCount} + if state.Returned { + out.SetReturned(true) + } + if state.BidAmount > 0 { + out.SetBidAmount(state.BidAmount) + out.SetBidDate(state.BidDate) + out.SetMinBidAmount(state.MinBidAmount) + out.SetBidPeer(tgPeer(state.BidPeer)) + } + return out +} + +func (r *Router) auctionUsers(ctx context.Context, viewerID int64, state domain.StarGiftAuction) []tg.UserClass { + ids := append([]int64(nil), state.TopBidders...) + if state.UserState.BidPeer.Type == domain.PeerTypeUser { + ids = append(ids, state.UserState.BidPeer.ID) + } + return tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(ids))) +} + +func (r *Router) starGiftSendUpdates(ctx context.Context, viewerID int64, send domain.SendPrivateTextResult) *tg.Updates { + message, event := send.SenderMessage, send.SenderEvent + if send.RecipientMessage.OwnerUserID == viewerID { + message, event = send.RecipientMessage, send.RecipientEvent + } else if send.SenderMessage.OwnerUserID != viewerID { + return emptyGiftUpdates(r.clock.Now().Unix()) + } + if message.ID <= 0 { + return emptyGiftUpdates(r.clock.Now().Unix()) + } + users := r.usersForMessageUpdate(ctx, viewerID, message) + chats := r.chatsForMessageUpdate(ctx, viewerID, message) + return tgPrivateMessageUpdates(event, message, 0, false, users, chats) +} + +func (r *Router) starGiftTransferUpdates(ctx context.Context, viewerID int64, result domain.StarGiftTransferResult, ton bool) *tg.Updates { + updates := r.starGiftSendUpdates(ctx, viewerID, result.Send) + currency := domain.StarGiftCurrencyStars + if ton { + currency = domain.StarGiftCurrencyTON + } + appendStarGiftBalanceUpdate(updates, currency, result.Balance.Balance) + return updates +} + +func appendStarGiftBalanceUpdate(updates *tg.Updates, currency domain.StarGiftCurrency, balance int64) { + if updates == nil { + return + } + var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance} + if currency == domain.StarGiftCurrencyTON { + amount = &tg.StarsTonAmount{Amount: balance} + } + updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: amount}) +} + +func emptyGiftUpdates(date int64) *tg.Updates { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(date)} +} + +func (r *Router) checkStarGiftOwnerPermission(ctx context.Context, userID int64, owner domain.Peer) error { + if owner == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return nil + } + if owner.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return peerIDInvalidErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, owner.ID) + if err != nil { + return channelInvalidErr(err) + } + if view.Self.Role == domain.ChannelRoleCreator || view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { + return nil + } + return tgerr.New(400, "CHAT_ADMIN_REQUIRED") +} + +func (r *Router) invalidateStarGiftOwner(owner domain.Peer) { + if owner.Type == domain.PeerTypeUser { + r.invalidateRPCProjectionForUser(owner.ID) + } else if owner.Type == domain.PeerTypeChannel { + r.invalidateRPCProjectionForChannel(owner.ID) + } +} + +func starGiftRefValue(ref domain.SavedStarGiftRef) string { + if ref.Slug != "" { + return "slug:" + strings.ToLower(strings.TrimSpace(ref.Slug)) + } + if ref.Owner.Type == domain.PeerTypeChannel { + return fmt.Sprintf("saved:%d", ref.SavedID) + } + return fmt.Sprintf("msg:%d", ref.MsgID) +} + +func uniqueInt64(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func starGiftLifecycleErr(err error) error { + switch { + case errors.Is(err, domain.ErrStarGiftFormExpired): + return formExpiredErr() + case errors.Is(err, domain.ErrStarGiftFormPurposeInvalid): + return purposeInvalidErr() + case errors.Is(err, domain.ErrStarGiftFormAmountMismatch): + return starsFormAmountMismatchErr() + case errors.Is(err, domain.ErrStarsInsufficient): + return tgerr.New(400, "BALANCE_TOO_LOW") + case errors.Is(err, domain.ErrPremiumRequired): + return tgerr.New(400, "PREMIUM_ACCOUNT_REQUIRED") + case errors.Is(err, domain.ErrStarGiftOfferExpired): + return tgerr.New(400, "STARGIFT_OFFER_EXPIRED") + case errors.Is(err, domain.ErrStarGiftOwnerInvalid): + return tgerr.New(400, "STARGIFT_OWNER_INVALID") + case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable): + return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE") + case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable), + errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid), + errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable), + errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid), + errors.Is(err, domain.ErrStarGiftCollectibleUnavailable): + return starGiftInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/payments_star_gift_unique.go b/internal/rpc/payments_star_gift_unique.go index b9b54794..6f9cd424 100644 --- a/internal/rpc/payments_star_gift_unique.go +++ b/internal/rpc/payments_star_gift_unique.go @@ -26,18 +26,37 @@ func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, i } func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) { - saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift) if err != nil { return nil, err } - wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails) - if formID == 0 || formID != wantFormID { - return nil, starsFormAmountMismatchErr() + commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails) + receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey) + if err != nil { + return nil, internalErr() + } + chargeStars := int64(0) + if replay { + if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid || + receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 { + return nil, starGiftInvalidErr() + } + chargeStars = receipt.ChargeStars + } else { + preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved) + if err != nil { + return nil, err + } + wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails) + if formID == 0 || formID != wantFormID { + return nil, starsFormAmountMismatchErr() + } + chargeStars = preview.UpgradeStars } result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{ - UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID}, - KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars, - FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails), + UserID: userID, Ref: starGiftUpgradeSavedRef(saved), + KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars, + FormID: formID, CommandKey: commandKey, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx), }) @@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments if err != nil { return nil, internalErr() } - saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift) if err != nil { return nil, err } - if saved.PrepaidUpgradeStars <= 0 { - return nil, starGiftInvalidErr() + commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails) + receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey) + if err != nil { + return nil, internalErr() + } + if replay { + if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid || + receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 { + return nil, starGiftInvalidErr() + } + } else { + if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil { + return nil, err + } + if saved.PrepaidUpgradeStars <= 0 { + return nil, starGiftInvalidErr() + } } result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{ - UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID}, + UserID: userID, Ref: starGiftUpgradeSavedRef(saved), KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true, - CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails), + CommandKey: commandKey, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx), }) @@ -79,33 +113,60 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments } func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) { - if r.deps.Gifts == nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr() - } - ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input) if err != nil { return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err } - if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID { - // Channel gift upgrades require a channel pts aggregate and are not silently - // routed through the private-message transaction. - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved) + return saved, preview, err +} + +func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) { + if r.deps.Gifts == nil { + return domain.SavedStarGift{}, notImplementedErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, input) + if err != nil { + return domain.SavedStarGift{}, err + } + if !ok { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil { + return domain.SavedStarGift{}, err } saved, found, err := r.deps.Gifts.GetSaved(ctx, ref) if err != nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() + return domain.SavedStarGift{}, internalErr() } - if !found || saved.Converted || saved.UniqueGiftID != 0 { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + if !found { + return domain.SavedStarGift{}, starGiftInvalidErr() + } + return saved, nil +} + +func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) { + if saved.Converted || saved.UniqueGiftID != 0 { + return domain.StarGiftUpgradePreview{}, starGiftInvalidErr() } preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID) if err != nil { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr() + return domain.StarGiftUpgradePreview{}, internalErr() } if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal { - return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr() + return domain.StarGiftUpgradePreview{}, starGiftInvalidErr() } - return saved, preview, nil + return preview, nil +} + +func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef { + ref := domain.SavedStarGiftRef{Owner: saved.Owner} + if saved.Owner.Type == domain.PeerTypeChannel { + ref.SavedID = saved.SavedID + } else { + ref.MsgID = saved.MsgID + } + return ref } func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates { @@ -116,6 +177,17 @@ func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64 updates := tgPrivateMessageUpdates(event, message, 0, false, r.usersForMessageUpdate(ctx, ownerUserID, message), r.chatsForMessageUpdate(ctx, ownerUserID, message)) + for _, edit := range result.SourceEdits { + if edit.UserID != ownerUserID { + continue + } + if update := tgOtherUpdateFromEvent(edit.Event); update != nil { + updates.Updates = append(updates.Updates, update) + if edit.Event.Date > updates.Date { + updates.Date = edit.Event.Date + } + } + } if includeBalance { updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}}) } @@ -175,6 +247,20 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID }, nil } +func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) { + if giftID <= 0 || r.deps.Gifts == nil { + return nil, starGiftInvalidErr() + } + preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, starGiftInvalidErr() + } + return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil +} + func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) { if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" { return nil, starGiftInvalidErr() @@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) ( func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass { out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) for _, attribute := range preview.Models { + if attribute.Crafted { + continue + } out = append(out, tgStarGiftAttribute(attribute)) } for _, attribute := range preview.Patterns { @@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta return out } +func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass { + out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops)) + for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} { + for _, attribute := range attributes { + out = append(out, tgStarGiftAttribute(attribute)) + } + } + return out +} + func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass { - rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille} + rarity := tgStarGiftAttributeRarity(attribute) switch attribute.Kind { case domain.StarGiftCollectibleModel: document := tg.DocumentClass(&tg.DocumentEmpty{}) if attribute.Document != nil { document = tgDocument(*attribute.Document) } - return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity} + return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted} case domain.StarGiftCollectiblePattern: document := tg.DocumentClass(&tg.DocumentEmpty{}) if attribute.Document != nil { @@ -248,6 +347,21 @@ func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarG } } +func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass { + switch attribute.RarityKind { + case domain.StarGiftRarityUncommon: + return &tg.StarGiftAttributeRarityUncommon{} + case domain.StarGiftRarityRare: + return &tg.StarGiftAttributeRarityRare{} + case domain.StarGiftRarityEpic: + return &tg.StarGiftAttributeRarityEpic{} + case domain.StarGiftRarityLegendary: + return &tg.StarGiftAttributeRarityLegendary{} + default: + return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille} + } +} + func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique { attributes := []tg.StarGiftAttributeClass{ tgStarGiftAttribute(unique.Model), @@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique { attributes = append(attributes, original) } out := &tg.StarGiftUnique{ + RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly, + ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted, ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num, Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal, } - if owner := tgPeer(unique.Owner); owner != nil { + if unique.OwnerAddress != "" { + out.SetOwnerAddress(unique.OwnerAddress) + } else if owner := tgPeer(unique.Owner); owner != nil { out.SetOwnerID(owner) + } else if unique.OwnerName != "" { + out.SetOwnerName(unique.OwnerName) + } + if unique.GiftAddress != "" { + out.SetGiftAddress(unique.GiftAddress) + } + if unique.ResellAmount != nil { + out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)}) + } + if peer := tgPeer(unique.ReleasedBy); peer != nil { + out.SetReleasedBy(peer) + } + if unique.ValueAmount > 0 { + out.SetValueAmount(unique.ValueAmount) + } + if unique.ValueCurrency != "" { + out.SetValueCurrency(unique.ValueCurrency) + } + if unique.ValueUSD > 0 { + out.SetValueUsdAmount(unique.ValueUSD) + } + if peer := tgPeer(unique.ThemePeer); peer != nil { + out.SetThemePeer(peer) + } + if peer := tgPeer(unique.Host); peer != nil { + out.SetHostID(peer) + } + if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser { + out.SetOfferMinStars(unique.OfferMinStars) + } + if unique.CraftChancePermille > 0 { + out.SetCraftChancePermille(unique.CraftChancePermille) } return out } + +func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass { + if amount.Currency == domain.StarGiftCurrencyTON { + return &tg.StarsTonAmount{Amount: amount.Amount} + } + return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos} +} + +func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) { + switch value := amount.(type) { + case *tg.StarsAmount: + if value == nil { + return domain.StarGiftAmount{}, false + } + out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos} + return out, out.Valid() + case *tg.StarsTonAmount: + if value == nil { + return domain.StarGiftAmount{}, false + } + out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount} + return out, out.Valid() + default: + return domain.StarGiftAmount{}, false + } +} diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index 28a3a278..d74864cf 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -2,12 +2,17 @@ package rpc import ( "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" + "strings" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap" + "telesrv/internal/branding" "telesrv/internal/domain" ) @@ -80,6 +85,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.starGiftUpgradePaymentForm(ctx, userID, inv) } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok { + return r.starGiftTransferPaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok { + return r.starGiftResalePaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok { + return r.starGiftAuctionBidPaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok { + return r.starGiftPrepaidUpgradePaymentForm(ctx, userID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok { + return r.starGiftDropDetailsPaymentForm(ctx, userID, inv) + } inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) if !ok { @@ -92,16 +112,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if err != nil { return nil, err } - if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser { - // Channel upgrades remain blocked until they can advance channel pts and - // publish a durable channel update. Never collect a prepaid upgrade that - // the recipient cannot consume. - return nil, starGiftInvalidErr() - } gift, err := r.starGiftFromCatalog(ctx, inv.GiftID) if err != nil { return nil, err } + if gift.RequirePremium && !r.viewerPremium(ctx, userID) { + return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } upgradeStars := int64(0) if inv.IncludeUpgrade { if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { @@ -109,8 +126,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG } upgradeStars = gift.UpgradeStars } + giftMessage := "" + if m, ok := inv.GetMessage(); ok { + giftMessage = clampGiftMessage(m.Text) + } + now := int(r.clock.Now().Unix()) + form, err := r.deps.Gifts.IssuePurchaseForm(ctx, domain.StarGiftPurchaseForm{ + BuyerUserID: userID, To: peer, GiftID: gift.ID, RevisionID: gift.RevisionID, + IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage, + ChargeStars: gift.Stars + upgradeStars, IssuedAt: now, ExpiresAt: now + 600, + }) + if err != nil { + return nil, starGiftLifecycleErr(err) + } return &tg.PaymentsPaymentFormStarGift{ - FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade), + FormID: form.FormID, Invoice: tg.Invoice{ Currency: "XTR", Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}}, @@ -139,11 +169,29 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv) } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok { + return r.sendStarGiftTransferForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok { + return r.sendStarGiftResaleForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok { + return r.sendStarGiftAuctionBidForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok { + return r.sendStarGiftPrepaidUpgradeForm(ctx, userID, req.FormID, inv) + } + if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok { + return r.sendStarGiftDropDetailsForm(ctx, userID, req.FormID, inv) + } inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) if !ok { return nil, notImplementedErr() } + if req.FormID == 0 { + return nil, formIDEmptyErr() + } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) if err != nil { return nil, err @@ -151,15 +199,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 { return nil, peerIDInvalidErr() } - if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser { - return nil, starGiftInvalidErr() - } if r.deps.Stars == nil || r.deps.Gifts == nil { return nil, notImplementedErr() } - if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil { - return nil, notImplementedErr() - } if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil { return nil, notImplementedErr() } @@ -167,6 +209,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe if err != nil { return nil, err } + buyerPremium := r.viewerPremium(ctx, userID) + if gift.RequirePremium && !buyerPremium { + return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } upgradeStars := int64(0) if inv.IncludeUpgrade { if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal { @@ -174,27 +220,58 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe } upgradeStars = gift.UpgradeStars } - if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) { - return nil, starsFormAmountMismatchErr() - } giftMessage := "" if m, ok := inv.GetMessage(); ok { giftMessage = clampGiftMessage(m.Text) } + now := int(r.clock.Now().Unix()) + purchaseReq := domain.StarGiftPurchaseRequest{BuyerUserID: userID, BuyerPremium: buyerPremium, To: peer, + GiftID: gift.ID, RevisionID: gift.RevisionID, IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage, + ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now, + OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)} + recipientBlocked := false + if peer.Type == domain.PeerTypeUser { + recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID) + if err != nil { + return nil, internalErr() + } + } + if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() { + if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil { + return nil, starGiftLifecycleErr(err) + } + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + return r.sendStarGiftMemoryPurchase(ctx, userID, peer, gift, inv, giftMessage, upgradeStars) + } + if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil { + return nil, starsErr(err) + } + purchaseReq.RecipientBlocked = recipientBlocked + result, err := r.deps.Gifts.Purchase(ctx, purchaseReq) + if err != nil { + return nil, starGiftLifecycleErr(err) + } + updates := r.starGiftSendUpdates(ctx, userID, result.Send) + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance) + r.invalidateStarGiftOwner(peer) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} - // 1. Debit 送礼人(不足→BALANCE_TOO_LOW)。 +func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift, + inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) { purchaseStars := gift.Stars + upgradeStars balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title) if err != nil { return nil, starsErr(err) } - var updates *tg.Updates switch peer.Type { case domain.PeerTypeUser: updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars) case domain.PeerTypeChannel: - updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage) + updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars) default: err = domain.ErrStarGiftInvalid } @@ -202,18 +279,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe r.refundStarGift(ctx, userID, peer, gift, purchaseStars) return nil, internalErr() } - - // 4. 构建送礼人 Updates(服务消息 + updateStarsBalance)。 - if updates != nil { - updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}) - } else { - updates = &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - } + if updates == nil { + updates = emptyGiftUpdates(r.clock.Now().Unix()) } + appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance) return &tg.PaymentsPaymentResult{Updates: updates}, nil } @@ -256,11 +325,11 @@ func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePayme return &tg.PaymentsPaymentFormStars{ FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount), BotID: domain.OfficialSystemUserID, - Title: "Telegram Stars", + Title: branding.StarsName, Description: "telesrv dev Stars top-up", Invoice: tg.Invoice{ Currency: "XTR", - Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}}, + Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}}, }, Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}), } @@ -295,8 +364,16 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i } func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) { + prepaidUpgradeHash := "" + if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, err + } + prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:]) + } // 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。 - send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars) + send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash) if err != nil { return nil, err } @@ -312,6 +389,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i Unsaved: false, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: prepaidUpgradeStars, + PrepaidUpgradeHash: prepaidUpgradeHash, Message: message, }); err != nil { return nil, err @@ -324,38 +402,40 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil } -func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) { +func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) { now := int(r.clock.Now().Unix()) sticker := gift.Sticker action := domain.ChannelMessageAction{ Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ - GiftID: gift.ID, - Stars: gift.Stars, - ConvertStars: gift.ConvertStars, - Title: gift.Title, - Sticker: &sticker, - Message: message, - FromUserID: senderID, - NameHidden: hideName, - Saved: true, - CanUpgrade: false, - PrepaidUpgrade: false, - UpgradeStars: 0, + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + NameHidden: hideName, + Saved: true, + CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: prepaidUpgradeStars > 0, + UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: prepaidUpgradeStars, }, } savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{ - Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - FromUserID: senderID, - GiftID: gift.ID, - RevisionID: gift.RevisionID, - MsgID: 0, - SavedID: 0, - Date: now, - NameHidden: hideName, - Unsaved: false, - ConvertStars: gift.ConvertStars, - Message: message, + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + FromUserID: senderID, + GiftID: gift.ID, + RevisionID: gift.RevisionID, + MsgID: 0, + SavedID: 0, + Date: now, + NameHidden: hideName, + Unsaved: false, + ConvertStars: gift.ConvertStars, + PrepaidUpgradeStars: prepaidUpgradeStars, + Message: message, }) if err != nil { return nil, err @@ -375,7 +455,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID } // deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。 -func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) { +func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64, prepaidUpgradeHash string) (domain.SendPrivateTextResult, error) { recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID) if err != nil { return domain.SendPrivateTextResult{}, err @@ -387,19 +467,21 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6 ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{ - GiftID: gift.ID, - Stars: gift.Stars, - ConvertStars: gift.ConvertStars, - Title: gift.Title, - Sticker: &sticker, - Message: message, - FromUserID: senderID, - PeerUserID: recipientID, - NameHidden: hideName, - Saved: true, - CanUpgrade: gift.UpgradeStars > 0, - PrepaidUpgrade: prepaidUpgradeStars > 0, - UpgradeStars: gift.UpgradeStars, + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + PeerUserID: recipientID, + NameHidden: hideName, + Saved: true, + CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: prepaidUpgradeStars > 0, + PrepaidUpgradeHash: prepaidUpgradeHash, + UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: prepaidUpgradeStars, }, }, } @@ -529,13 +611,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav return true, nil } -// onPaymentsConvertStarGift 把收到的礼物转换回 Stars(Credit + 标记 converted)。 +// onPaymentsConvertStarGift atomically destroys the regular gift and credits +// the owner-scoped internal Stars ledger. Channel proceeds never leak to the +// acting administrator's personal balance. func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { return false, internalErr() } - if r.deps.Gifts == nil || r.deps.Stars == nil { + if r.deps.Gifts == nil { return false, notImplementedErr() } dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref) @@ -545,10 +629,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave if !ok { return false, starGiftInvalidErr() } - if dref.Owner.Type == domain.PeerTypeChannel { - return false, notImplementedErr() + if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil { + return false, err } - saved, err := r.deps.Gifts.Convert(ctx, dref) + // The isolated memory RPC adapter intentionally has no aggregate store. Keep + // its conversion primitive usable for tests, but never use this split write + // path when the production lifecycle coordinator is configured. Channel + // balances have no memory adapter because crediting an administrator would + // violate owner-scoped accounting. + if converter, ok := r.deps.Gifts.(interface { + AtomicPurchaseConfigured() bool + Convert(context.Context, domain.SavedStarGiftRef) (domain.SavedStarGift, error) + }); ok && !converter.AtomicPurchaseConfigured() { + if dref.Owner.Type != domain.PeerTypeUser || dref.Owner.ID != userID { + return false, notImplementedErr() + } + updated, convertErr := converter.Convert(ctx, dref) + if convertErr != nil { + if errors.Is(convertErr, domain.ErrStarGiftNotFound) || errors.Is(convertErr, domain.ErrStarGiftAlreadyConverted) { + return false, starGiftInvalidErr() + } + return false, internalErr() + } + if updated.ConvertStars > 0 { + if _, creditErr := r.deps.Stars.Credit(ctx, userID, updated.ConvertStars, domain.StarsReasonGift, + dref.Owner, "Star gift conversion", "Converted Star Gift"); creditErr != nil { + return false, internalErr() + } + } + r.invalidateStarGiftOwnerProjection(dref.Owner) + return true, nil + } + result, err := r.deps.Gifts.ConvertAggregate(ctx, domain.StarGiftConvertRequest{ + ActorUserID: userID, + Ref: dref, + Date: int(r.clock.Now().Unix()), + }) if err != nil { switch { case errors.Is(err, domain.ErrStarGiftNotFound): @@ -559,15 +675,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave return false, internalErr() } } - if saved.ConvertStars > 0 { - fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID} - if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil { - r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err)) - return false, internalErr() - } - } // 转换移除一份展示礼物 → 失效 owner full 投影。 - r.invalidateStarGiftOwnerProjection(dref.Owner) + r.invalidateStarGiftOwnerProjection(result.Saved.Owner) return true, nil } @@ -622,6 +731,24 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg. return domain.SavedStarGiftRef{}, false, peerIDInvalidErr() } return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil + case *tg.InputSavedStarGiftSlug: + if v == nil || r.deps.Gifts == nil { + return domain.SavedStarGiftRef{}, false, nil + } + slug := strings.ToLower(strings.TrimSpace(v.Slug)) + if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes { + return domain.SavedStarGiftRef{}, false, nil + } + unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug) + if err != nil { + return domain.SavedStarGiftRef{}, false, internalErr() + } + if !found || unique.Slug == "" || unique.Owner.ID == 0 || + (unique.Owner.Type != domain.PeerTypeUser && unique.Owner.Type != domain.PeerTypeChannel) { + return domain.SavedStarGiftRef{}, false, nil + } + resolved := domain.SavedStarGiftRef{Owner: unique.Owner, Slug: strings.ToLower(strings.TrimSpace(unique.Slug))} + return resolved, resolved.Valid(), nil default: return domain.SavedStarGiftRef{}, false, nil } @@ -718,11 +845,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif if gift.UniqueGiftID != 0 { continue } - if gift.Owner.Type != domain.PeerTypeUser { - // Channel upgrade RPCs are deliberately blocked until the channel pts - // aggregate exists, so do not advertise a dead-end action. - continue - } if _, ok := seen[gift.GiftID]; ok { continue } @@ -755,10 +877,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass { // tgStarGift 把目录项投影为 tg.StarGift(Sticker 须为带 sticker 属性的有效 Document)。 func tgStarGift(g domain.StarGift) *tg.StarGift { gift := &tg.StarGift{ - ID: g.ID, - Sticker: tgDocument(g.Sticker), - Stars: g.Stars, - ConvertStars: g.ConvertStars, + Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday, + RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser, + PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction, + ID: g.ID, Sticker: tgDocument(g.Sticker), Stars: g.Stars, ConvertStars: g.ConvertStars, + } + if g.Limited { + gift.SetAvailabilityRemains(g.AvailabilityRemains) + gift.SetAvailabilityTotal(g.AvailabilityTotal) + } + if g.AvailabilityResale > 0 { + gift.SetAvailabilityResale(g.AvailabilityResale) + } + // sold_out, first_sale_date and last_sale_date share TL flags.1. The store + // retains sale timestamps for live gifts as operational facts, but exposing + // either timestamp would make every client decode the gift as sold out. + if g.SoldOut { + gift.SetFirstSaleDate(g.FirstSaleDate) + gift.SetLastSaleDate(g.LastSaleDate) } if g.Title != "" { gift.SetTitle(g.Title) @@ -766,6 +902,31 @@ func tgStarGift(g domain.StarGift) *tg.StarGift { if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal { gift.SetUpgradeStars(g.UpgradeStars) } + if g.ResellMinStars > 0 { + gift.SetResellMinStars(g.ResellMinStars) + } + if releasedBy := tgPeer(g.ReleasedBy); releasedBy != nil { + gift.SetReleasedBy(releasedBy) + } + if g.LimitedPerUser { + gift.SetPerUserTotal(g.PerUserTotal) + gift.SetPerUserRemains(g.PerUserRemains) + } + if g.LockedUntilDate > 0 { + gift.SetLockedUntilDate(g.LockedUntilDate) + } + if g.Auction { + gift.SetAuctionSlug(g.AuctionSlug) + gift.SetGiftsPerRound(g.GiftsPerRound) + gift.SetAuctionStartDate(g.AuctionStartDate) + } + if g.UpgradeVariants > 0 { + gift.SetUpgradeVariants(g.UpgradeVariants) + } + if g.Background != nil { + gift.SetBackground(tg.StarGiftBackground{CenterColor: g.Background.CenterColor, + EdgeColor: g.Background.EdgeColor, TextColor: g.Background.TextColor}) + } return gift } @@ -787,6 +948,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC if in.Title != "" { gift.SetTitle(in.Title) } + if in.UpgradePriceStars > 0 { + gift.SetUpgradeStars(in.UpgradePriceStars) + } action := &tg.MessageActionStarGift{Gift: gift} if in.NameHidden { action.NameHidden = true @@ -799,12 +963,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC } action.CanUpgrade = in.CanUpgrade action.PrepaidUpgrade = in.PrepaidUpgrade + action.UpgradeSeparate = in.UpgradeSeparate + action.AuctionAcquired = in.AuctionAcquired if in.UpgradeStars > 0 { action.SetUpgradeStars(in.UpgradeStars) } if in.UpgradeMsgID > 0 { action.SetUpgradeMsgID(in.UpgradeMsgID) } + if in.PrepaidUpgradeHash != "" { + action.SetPrepaidUpgradeHash(in.PrepaidUpgradeHash) + } + if in.GiftMsgID > 0 { + action.SetGiftMsgID(in.GiftMsgID) + } + if in.GiftNum > 0 { + action.SetGiftNum(in.GiftNum) + } + if to := tgPeer(in.To); to != nil { + action.SetToID(to) + } if in.ConvertStars > 0 { action.SetConvertStars(in.ConvertStars) } @@ -889,6 +1067,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta item.SetUpgradeStars(g.PrepaidUpgradeStars) item.CanUpgrade = true } + if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue { + item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash) + } } if g.PinnedOrder > 0 { item.PinnedToTop = true @@ -898,6 +1079,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta } if g.Unique != nil { item.SetGiftNum(g.Unique.Num) + } else if g.GiftNum > 0 { + item.SetGiftNum(g.GiftNum) } out = append(out, item) } @@ -944,24 +1127,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 { return ids } -func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 { - return starGiftFormIDWithUpgrade(userID, peer, gift, false) -} - -func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 { - id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654 - if includeUpgrade { - id ^= gift.UpgradeStars<<29 ^ 0x55504752414445 - } - for _, ch := range string(peer.Type) { - id = id*131 + int64(ch) - } - if id == 0 { - id = 0x5347 - } - return id -} - func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 { id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253 for _, ch := range currency { diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 2080815f..7d7da827 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -21,6 +21,10 @@ import ( ) func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) { + return starGiftTestRouterWithPremium(t, false) +} + +func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, domain.User, domain.User, domain.StarGift) { t.Helper() ctx := context.Background() users := memory.NewUserStore() @@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain t.Fatalf("create recipient: %v", err) } gift := domain.StarGift{ - ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", + ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", RequirePremium: requirePremium, Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, } giftStore := memory.NewStarGiftStore() @@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain return r, sender, recipient, gift } +func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) { + r, sender, recipient, gift := starGiftTestRouterWithPremium(t, true) + ctx := WithUserID(context.Background(), sender.ID) + inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} + if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") { + t.Fatalf("non-premium gift form err = %v, want PREMIUM_ACCOUNT_REQUIRED", err) + } + premium, ok := r.deps.Users.(UserPremiumService) + if !ok { + t.Fatalf("users service %T does not implement premium grants", r.deps.Users) + } + if _, err := premium.GrantPremium(context.Background(), sender.ID, 1); err != nil { + t.Fatalf("grant premium: %v", err) + } + formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("premium gift form: %v", err) + } + form, ok := formRes.(*tg.PaymentsPaymentFormStarGift) + if !ok { + t.Fatalf("premium gift form = %T", formRes) + } + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil { + t.Fatalf("premium gift purchase: %v", err) + } +} + type uniqueGiftRPCService struct { GiftsService unique domain.UniqueStarGift @@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom return s.unique, slug == s.unique.Slug, nil } +type craftStarGiftRPCService struct { + GiftsService + uniques map[string]domain.UniqueStarGift + saved map[int64]domain.SavedStarGift + result domain.StarGiftCraftResult + craftReq domain.StarGiftCraftRequest + craftCall int +} + +func (s *craftStarGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) { + unique, ok := s.uniques[slug] + return unique, ok, nil +} + +func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + for _, saved := range s.saved { + if saved.Owner != ref.Owner { + continue + } + if ref.Slug != "" { + unique, ok := s.uniques[ref.Slug] + if ok && unique.ID == saved.UniqueGiftID { + return saved, true, nil + } + continue + } + if saved.MsgID == ref.MsgID { + return saved, true, nil + } + } + return domain.SavedStarGift{}, false, nil +} + +func (s *craftStarGiftRPCService) Craft(_ context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + s.craftCall++ + s.craftReq = req + return s.result, nil +} + +func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) { + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 7102} + service := &craftStarGiftRPCService{ + uniques: map[string]domain.UniqueStarGift{ + "official-8001-2": {ID: 902, Slug: "official-8001-2", Owner: owner, SourceSavedGiftID: 52}, + }, + saved: map[int64]domain.SavedStarGift{ + 50: {ID: 50, Owner: owner, MsgID: 115, UniqueGiftID: 901, UpgradeMsgID: 116}, + 52: {ID: 52, Owner: owner, MsgID: 111, UniqueGiftID: 902, UpgradeMsgID: 112}, + }, + result: domain.StarGiftCraftResult{Chance: 500, SourceEdits: []domain.EditedMessageForUser{{ + UserID: owner.ID, + Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}, + Event: domain.UpdateEvent{UserID: owner.ID, Type: domain.UpdateEventEditMessage, Pts: 41, PtsCount: 1, + Date: 100, Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}}, + }}}, + } + r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), owner.ID) + updates, err := r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 115}, + &tg.InputSavedStarGiftSlug{Slug: "OFFICIAL-8001-2"}, + }}) + if err != nil || updates == nil { + t.Fatalf("craft mixed official refs: updates=%T err=%v", updates, err) + } + if service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50,52" || len(service.craftReq.Refs) != 2 || + service.craftReq.Refs[1].Slug != "official-8001-2" { + t.Fatalf("craft request = %+v calls=%d", service.craftReq, service.craftCall) + } + full, ok := updates.(*tg.Updates) + if !ok || len(full.Updates) != 2 { + t.Fatalf("craft failure updates = %T %#v", updates, updates) + } + if edit, ok := full.Updates[0].(*tg.UpdateEditMessage); !ok || edit.Pts != 41 || edit.PtsCount != 1 { + t.Fatalf("craft failure source update = %T %#v", full.Updates[0], full.Updates[0]) + } + if _, ok := full.Updates[1].(*tg.UpdateStarGiftCraftFail); !ok { + t.Fatalf("craft terminal update = %T %#v", full.Updates[1], full.Updates[1]) + } + + service.craftCall = 0 + _, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 111}, + &tg.InputSavedStarGiftSlug{Slug: "official-8001-2"}, + }}) + if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { + t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall) + } + + _, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftUser{MsgID: 116}, + }}) + if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { + t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall) + } +} + +type upgradeReplayRPCService struct { + GiftsService + saved domain.SavedStarGift + receipt domain.StarGiftUpgradeReceipt + result domain.StarGiftUpgradeResult + upgradeCalls int + previewCalls int + lastRequest domain.StarGiftUpgradeRequest +} + +func (s *upgradeReplayRPCService) GetSaved(_ context.Context, _ domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + return s.saved, true, nil +} + +func (s *upgradeReplayRPCService) UpgradeReceipt(_ context.Context, userID int64, _ string) (domain.StarGiftUpgradeReceipt, bool, error) { + if userID != s.receipt.UserID { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + return s.receipt, true, nil +} + +func (s *upgradeReplayRPCService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) { + s.previewCalls++ + return domain.StarGiftUpgradePreview{}, false, nil +} + +func (s *upgradeReplayRPCService) Upgrade(_ context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { + s.upgradeCalls++ + s.lastRequest = req + return s.result, nil +} + func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute { - attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000} + attribute := domain.StarGiftCollectibleAttribute{ + Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + } if kind == domain.StarGiftCollectibleBackdrop { attribute.BackdropID = int(id) attribute.CenterColor = 0x112233 @@ -76,6 +238,10 @@ func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id in MimeType: "application/x-tgsticker", Size: 3, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}, {Kind: domain.DocAttrFilename, FileName: "gift.tgs"}}, } + if kind == domain.StarGiftCollectiblePattern { + attribute.Document.Attributes[0] = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, TextColor: true} + attribute.Document.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}} + } attribute.Animation = &domain.StarGiftAnimation{ SourceName: "gift.tgs", SourceFormat: domain.StarGiftAnimationTGS, JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: make([]byte, 32), Width: 512, Height: 512, @@ -148,6 +314,120 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA } } +func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) { + ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("ordinary action = %T", ordinary) + } + ordinaryGift, ok := ordinary.Gift.(*tg.StarGift) + if !ok { + t.Fatalf("ordinary inner gift = %T", ordinary.Gift) + } + if price, set := ordinaryGift.GetUpgradeStars(); !set || price != 75 { + t.Fatalf("ordinary inner upgrade_stars = %d set=%v, want paid price 75", price, set) + } + if amount, set := ordinary.GetUpgradeStars(); set || amount != 0 || ordinary.PrepaidUpgrade { + t.Fatalf("ordinary outer upgrade_stars = %d set=%v prepaid=%v, want absent", amount, set, ordinary.PrepaidUpgrade) + } + + prepaid, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, PrepaidUpgrade: true, + UpgradePriceStars: 75, UpgradeStars: 75, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("prepaid action = %T", prepaid) + } + if amount, set := prepaid.GetUpgradeStars(); !set || amount != 75 || !prepaid.PrepaidUpgrade { + t.Fatalf("prepaid outer upgrade_stars = %d set=%v prepaid=%v, want 75", amount, set, prepaid.PrepaidUpgrade) + } + upgraded, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ + GiftID: 8001, Stars: 50, ConvertStars: 25, UpgradeMsgID: 88, + }).(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("upgraded action = %T", upgraded) + } + if msgID, set := upgraded.GetUpgradeMsgID(); !set || msgID != 88 { + t.Fatalf("upgrade_msg_id = %d set=%v, want 88", msgID, set) + } + for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} { + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, ordinary, wire); err != nil { + t.Fatalf("encode Layer %d ordinary action: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d ordinary action: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.MessageActionStarGift) + if !ok { + t.Fatalf("decode Layer %d action = %T", profile, decodedObject) + } + inner, ok := decoded.Gift.(*tg.StarGift) + if !ok || inner.UpgradeStars != 75 || decoded.UpgradeStars != 0 || decoded.PrepaidUpgrade { + t.Fatalf("Layer %d ordinary action lost paid/prepaid split: %#v", profile, decoded) + } + + upgradedWire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, upgraded, upgradedWire); err != nil { + t.Fatalf("encode Layer %d upgraded action: %v", profile, err) + } + decodedUpgradedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: upgradedWire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d upgraded action: %v", profile, err) + } + decodedUpgraded, ok := decodedUpgradedObject.(*tg.MessageActionStarGift) + if !ok || !decodedUpgraded.Upgraded || decodedUpgraded.UpgradeMsgID != 88 || decodedUpgraded.CanUpgrade { + t.Fatalf("Layer %d upgraded action lost transition flags: %#v", profile, decodedUpgradedObject) + } + } +} + +func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) { + r, sender, owner, gift := starGiftTestRouter(t) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + saved := domain.SavedStarGift{ + ID: 47, Owner: ownerPeer, FromUserID: sender.ID, GiftID: gift.ID, RevisionID: gift.RevisionID, + MsgID: 105, UniqueGiftID: 9200000000000004, + } + result := domain.StarGiftUpgradeResult{ + Saved: saved, Unique: domain.UniqueStarGift{ID: saved.UniqueGiftID, GiftID: gift.ID, Owner: ownerPeer}, + Balance: domain.StarsBalance{UserID: owner.ID, Balance: 1000}, Duplicate: true, + Send: domain.SendPrivateTextResult{ + RecipientMessage: domain.Message{ID: 107, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Date: 1700000001}, + RecipientEvent: domain.UpdateEvent{UserID: owner.ID, Pts: 42, PtsCount: 1, Date: 1700000001}, + }, + } + service := &upgradeReplayRPCService{GiftsService: r.deps.Gifts, saved: saved, result: result, + receipt: domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID, + UniqueGiftID: saved.UniqueGiftID, RequirePrepaid: true, KeepOriginalDetails: true, BalanceAfter: 1000}} + r.deps.Gifts = service + ctx := WithUserID(context.Background(), owner.ID) + if _, err := r.onPaymentsUpgradeStarGift(ctx, &tg.PaymentsUpgradeStarGiftRequest{ + KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID}, + }); err != nil { + t.Fatalf("replay prepaid upgrade after terminal transition: %v", err) + } + if service.upgradeCalls != 1 || service.previewCalls != 0 || !service.lastRequest.RequirePrepaid || service.lastRequest.ChargeStars != 0 { + t.Fatalf("prepaid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest) + } + + const paidFormID int64 = -7611777087885039132 + service.receipt = domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID, + FormID: paidFormID, UniqueGiftID: saved.UniqueGiftID, ChargeStars: 25, + KeepOriginalDetails: true, BalanceAfter: 975} + service.upgradeCalls, service.previewCalls = 0, 0 + if _, err := r.sendStarGiftUpgradeForm(ctx, owner.ID, paidFormID, &tg.InputInvoiceStarGiftUpgrade{ + KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID}, + }); err != nil { + t.Fatalf("replay paid upgrade after terminal transition: %v", err) + } + if service.upgradeCalls != 1 || service.previewCalls != 0 || service.lastRequest.ChargeStars != 25 || service.lastRequest.FormID != paidFormID { + t.Fatalf("paid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest) + } +} + func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) { r, sender, owner, gift := starGiftTestRouter(t) ctx := context.Background() @@ -157,11 +437,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test t.Fatalf("gift service = %T", r.deps.Gifts) } model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora") + crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora") + crafted.Crafted = true + crafted.RarityKind = domain.StarGiftRarityLegendary + crafted.RarityPermille = 0 pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit") backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight") if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake", - Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern}, + Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern}, Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc", }); err != nil { t.Fatalf("publish collectible pool: %v", err) @@ -177,6 +461,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test if err != nil || len(preview.SampleAttributes) != 3 { t.Fatalf("upgrade preview = %#v err %v", preview, err) } + attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID) + if err != nil || len(attributes.Attributes) != 4 { + t.Fatalf("upgrade attributes = %#v err %v", attributes, err) + } + craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel) + if !ok || !craftedTG.Crafted { + t.Fatalf("crafted attribute = %T %#v", attributes.Attributes[1], attributes.Attributes[1]) + } + if _, ok := craftedTG.Rarity.(*tg.StarGiftAttributeRarityLegendary); !ok { + t.Fatalf("crafted rarity = %T", craftedTG.Rarity) + } invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}} formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice}) if err != nil { @@ -208,7 +503,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true, + Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, SavedID: 444, Upgrade: true, Saved: true, }, }}} action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique) @@ -224,6 +519,9 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test } else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID { t.Fatalf("unique service action peer = %#v", peer) } + if savedID, ok := action.GetSavedID(); !ok || savedID != 444 { + t.Fatalf("unique service action saved_id = %d set=%v, want 444", savedID, ok) + } for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} { responseWire := &bin.Buffer{} if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil { @@ -254,7 +552,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test if !ok { t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject) } - if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug { + if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedAction.SavedID != 444 || decodedActionGift.Slug != unique.Slug { t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction) } } @@ -556,10 +854,15 @@ func TestStarGiftChannelSaga(t *testing.T) { }); err != nil { t.Fatalf("publish channel collectible pool: %v", err) } - if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{ + upgradeFormRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{ Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true, - }}); err == nil { - t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked") + }}) + if err != nil { + t.Fatalf("getPaymentForm(channel include_upgrade): %v", err) + } + upgradeForm, ok := upgradeFormRes.(*tg.PaymentsPaymentFormStarGift) + if !ok || len(upgradeForm.Invoice.Prices) != 1 || upgradeForm.Invoice.Prices[0].Amount != gift.Stars+75 { + t.Fatalf("channel include_upgrade form = %T %+v, want total %d", upgradeFormRes, upgradeFormRes, gift.Stars+75) } inv := &tg.InputInvoiceStarGift{ Peer: channelPeer, @@ -612,8 +915,8 @@ func TestStarGiftChannelSaga(t *testing.T) { if savedRes.Count != 1 || len(savedRes.Gifts) != 1 { t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts)) } - if savedRes.Gifts[0].CanUpgrade { - t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked") + if !savedRes.Gifts[0].CanUpgrade { + t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available") } savedID, ok := savedRes.Gifts[0].GetSavedID() if !ok || savedID <= 0 { @@ -728,8 +1031,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) { }, zaptest.NewLogger(t), clock.System) senderCtx := WithUserID(ctx, sender.ID) inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} - peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} - if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil { + formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("get expensive gift form: %v", err) + } + form := formRes.(*tg.PaymentsPaymentFormStarGift) + if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "BALANCE_TOO_LOW") { t.Fatalf("over-budget gift should error BALANCE_TOO_LOW") } // 余额未变。 @@ -738,22 +1045,60 @@ func TestStarGiftInsufficientBalance(t *testing.T) { } } -func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) { +func TestStarGiftPurchaseFormsAreFreshAndBindPurpose(t *testing.T) { r, sender, recipient, gift := starGiftTestRouter(t) ctx := WithUserID(context.Background(), sender.ID) - peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} - base := starGiftFormID(sender.ID, peer, gift) - changedRevision := gift - changedRevision.RevisionID++ - changedPrice := gift - changedPrice.Stars++ - changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1} - if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) { - t.Fatal("star gift form id must bind revision, price and recipient") - } inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} - if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { - t.Fatalf("bad form err=%v", err) + firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("first form: %v", err) + } + secondRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("second form: %v", err) + } + first := firstRes.(*tg.PaymentsPaymentFormStarGift) + second := secondRes.(*tg.PaymentsPaymentFormStarGift) + if first.FormID == 0 || second.FormID == 0 || first.FormID == second.FormID { + t.Fatalf("fresh form ids = %d/%d, want distinct non-zero TL longs", first.FormID, second.FormID) + } + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID + second.FormID, Invoice: inv}); !tgerr.Is(err, "FORM_EXPIRED") { + t.Fatalf("unknown form err=%v, want FORM_EXPIRED", err) + } + tampered := *inv + tampered.HideName = true + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID, Invoice: &tampered}); !tgerr.Is(err, "PURPOSE_INVALID") { + t.Fatalf("tampered form err=%v, want PURPOSE_INVALID", err) + } +} + +func TestStarGiftCanPurchaseSameCatalogGiftTwice(t *testing.T) { + r, sender, recipient, gift := starGiftTestRouter(t) + ctx := WithUserID(context.Background(), sender.ID) + inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} + var formIDs []int64 + for i := 0; i < 2; i++ { + formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("get form %d: %v", i, err) + } + form := formRes.(*tg.PaymentsPaymentFormStarGift) + formIDs = append(formIDs, form.FormID) + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil { + t.Fatalf("purchase %d: %v", i, err) + } + } + if formIDs[0] == formIDs[1] { + t.Fatalf("repeated purchase reused form id %d", formIDs[0]) + } + saved, err := r.onPaymentsGetSavedStarGifts(WithUserID(context.Background(), recipient.ID), &tg.PaymentsGetSavedStarGiftsRequest{ + Peer: &tg.InputPeerSelf{}, Limit: 10, + }) + if err != nil { + t.Fatalf("get recipient gifts: %v", err) + } + if saved.Count != 2 || len(saved.Gifts) != 2 { + t.Fatalf("recipient gifts = count %d len %d, want two independent gifts", saved.Count, len(saved.Gifts)) } } diff --git a/internal/rpc/payments_stars_rpc_test.go b/internal/rpc/payments_stars_rpc_test.go index 3b04e5a5..76702ca1 100644 --- a/internal/rpc/payments_stars_rpc_test.go +++ b/internal/rpc/payments_stars_rpc_test.go @@ -85,6 +85,22 @@ func TestOnPaymentsGetStarsTransactions(t *testing.T) { } } +func TestTGStarsTransactionsPaidMessage(t *testing.T) { + out := tgStarsTransactions([]domain.StarsTransaction{{ + ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50}, + Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message", + }}) + if len(out) != 1 { + t.Fatalf("paid-message transactions = %d, want 1", len(out)) + } + if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 { + t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok) + } + if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 { + t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount) + } +} + // deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。 func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) @@ -98,3 +114,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { } _ = domain.DefaultStarsStartingGrant } + +type channelLedgerGifts struct { + GiftsService + starsBalance int64 + tonBalance int64 + starsPage domain.StarsTransactionPage + tonPage domain.TonTransactionPage +} + +func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) { + return s.starsBalance, nil +} + +func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, string, int) (domain.StarsTransactionPage, error) { + return s.starsPage, nil +} + +func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) { + return s.tonBalance, nil +} + +func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, string, int) (domain.TonTransactionPage, error) { + return s.tonPage, nil +} + +type channelLedgerChannels struct { + ChannelsService + view domain.ChannelView +} + +func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + return s.view, nil +} + +func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) { + return []domain.ChannelView{s.view}, nil +} + +func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) { + const viewerID, channelID int64 = 1000000001, 2000000001 + view := domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, + } + gifts := &channelLedgerGifts{ + starsBalance: 20, + tonBalance: 900, + starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{ + ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift, + }}}, + tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{ + ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale, + }}}, + } + r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), viewerID) + peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash} + + status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer}) + if err != nil { + t.Fatalf("get channel stars status: %v", err) + } + if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 { + t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats)) + } + revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}) + if err != nil { + t.Fatalf("get channel stars revenue: %v", err) + } + if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 { + t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance) + } + if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled { + t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled) + } + + txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20} + txnReq.SetTon(true) + transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq) + if err != nil { + t.Fatalf("get channel ton transactions: %v", err) + } + history, ok := transactions.GetHistory() + if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale { + t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history) + } + revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer} + revenueReq.SetTon(true) + tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq) + if err != nil { + t.Fatalf("get channel ton revenue: %v", err) + } + if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 { + t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance) + } +} + +func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) { + const viewerID, channelID int64 = 1000000001, 2000000001 + view := domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive}, + } + r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), viewerID) + _, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}}) + if err == nil { + t.Fatal("non-admin channel ledger read unexpectedly succeeded") + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index b90c31c4..97a560b8 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -93,7 +93,7 @@ type Config struct { // Router 把解密后的 RPC 请求按 semantic method 路由到 typed handler(tlprofile.Dispatcher)。 // -// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler +// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler // 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳: // 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。 type Router struct { @@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { if instanceID == "" { instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) } - r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} + r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer) r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer) r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) @@ -260,6 +260,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerMessages(d) r.registerStickers(d) r.registerChannels(d) + r.registerCommunities(d) r.registerUpload(d) r.registerPhotos(d) r.registerFolders(d) @@ -274,6 +275,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerPremium(d) r.registerAiCompose(d) r.registerBots(d) + r.registerEphemeral(d) r.dispatcher = d return r diff --git a/internal/rpc/router_dispatch_test.go b/internal/rpc/router_dispatch_test.go index e7412276..097c829c 100644 --- a/internal/rpc/router_dispatch_test.go +++ b/internal/rpc/router_dispatch_test.go @@ -1431,7 +1431,7 @@ func TestMessagesSearchGlobalExactLayerProfiles(t *testing.T) { } } -func TestMessagesSearchGlobalCommunityProjectionFailsClosedForLayer227(t *testing.T) { +func TestMessagesSearchGlobalCommunityFieldIsAbsentFromLayer227Wire(t *testing.T) { request := &tg.MessagesSearchGlobalRequest{ Q: "scoped", Filter: &tg.InputMessagesFilterEmpty{}, @@ -1453,11 +1453,25 @@ func TestMessagesSearchGlobalCommunityProjectionFailsClosedForLayer227(t *testin } var body227 bin.Buffer - if err := tlprofile.EncodeObject(tlprofile.Profile227, request, &body227); err == nil { - t.Fatal("Layer 227 projection accepted a Layer 228-only community scope") + if err := tlprofile.EncodeObject(tlprofile.Profile227, request, &body227); err != nil { + t.Fatalf("encode Layer 227 searchGlobal: %v", err) + } + decoded, err := tlprofile.DecodeObject(tlprofile.Profile227, &bin.Buffer{Buf: body227.Copy()}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer 227 searchGlobal: %v", err) + } + legacy, ok := decoded.(*tg.MessagesSearchGlobalRequest) + if !ok { + t.Fatalf("decoded Layer 227 request = %T", decoded) + } + if _, ok := legacy.GetCommunity(); ok { + t.Fatal("Layer 227 wire retained the Layer 228-only community scope") + } + if _, err := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System).AdmitLayer(tlprofile.Profile227, &body227, tlprofile.Limits{}); err != nil { + t.Fatalf("admit Layer 227 searchGlobal: %v", err) } if body227.Len() != 0 { - t.Fatalf("failed Layer 227 projection emitted %d partial bytes", body227.Len()) + t.Fatalf("Layer 227 community-free admission left %d bytes", body227.Len()) } } diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index 5912bf8b..2c90df66 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -37,7 +37,7 @@ type outgoingSend struct { sendAs *domain.Peer sendAsReady bool clearDraft bool - // replyMarkup 是 bot inline keyboard(已解析+校验;非 bot 恒 nil)。 + // replyMarkup 是 bot reply/inline keyboard(已解析+校验;非 bot 恒 nil)。 replyMarkup *domain.MessageReplyMarkup viaBotID int64 // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 @@ -274,6 +274,89 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if !ok || peer.ID == 0 { return nil, peerIDInvalidErr() } + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + var mono domain.Channel + var monoforum, monoforumAdmin bool + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) + switch { + case err == nil: + monoforum = true + case !errors.Is(err, domain.ErrChannelInvalid): + return nil, internalErr() + } + } + if hasSuggestedPost && !monoforum { + return nil, suggestedPostPeerInvalidErr() + } + if monoforum { + if req.AllowPaidStars < 0 { + return nil, starsAmountInvalidErr() + } + if req.AllowPaidFloodskip { + return nil, paymentUnsupportedErr() + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return nil, scheduleDateInvalidErr() + } + suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if err != nil { + return nil, err + } + savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo) + if err != nil { + return nil, err + } + replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return nil, err + } + replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint) + if err != nil { + return nil, err + } + if replay.found { + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil + } + if r.messageEffectInvalid(ctx, req.Effect) { + return nil, effectIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + return nil, err + } + checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + media, err := r.resolveInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + return r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{ + SavedPeer: savedPeer, + RandomID: req.RandomID, + IdempotencyFingerprint: idempotencyFingerprint, + IdempotencyPreflighted: replay.checked, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + Media: media, + ReplyTo: replyTo, + Silent: req.Silent, + NoForwards: req.Noforwards, + SuggestedPost: suggestedPost, + AllowPaidStars: req.AllowPaidStars, + ClearDraft: req.ClearDraft, + }) + } + if req.AllowPaidStars > 0 || req.AllowPaidFloodskip { + return nil, paymentUnsupportedErr() + } replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint) if err != nil { return nil, err @@ -299,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if media == nil { return nil, mediaInvalidErr() } - // reply_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。 + // reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force; + // 非 bot 静默丢弃。 var replyMarkup *domain.MessageReplyMarkup if req.ReplyMarkup != nil { - replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) if err != nil { return nil, replyMarkupErr(err) } + if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil { + return nil, err + } } if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index c4ab37d8..a7e4364b 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -1067,21 +1067,36 @@ func (r *Router) onStoriesGetPeerMaxIDs(ctx context.Context, id []tg.InputPeerCl return nil, internalErr() } peers := make([]domain.Peer, 0, len(id)) - for _, input := range id { + positions := make([]int, 0, len(id)) + result := make([]tg.RecentStory, len(id)) + for i, input := range id { + if _, community, err := r.maybeCommunityFromInputPeer(ctx, userID, input); err != nil { + return nil, err + } else if community { + // Communities are projected as channel peers in dialog lists, but do + // not own stories. Keep the batch positional by returning an empty + // recentStory at this index and resolve every ordinary peer normally. + continue + } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) if err != nil { return nil, err } peers = append(peers, peer) + positions = append(positions, i) } if r.deps.Stories == nil || userID == 0 { - return make([]tg.RecentStory, len(id)), nil + return result, nil } recent, err := r.deps.Stories.GetPeerMaxIDs(ctx, userID, peers, int(r.clock.Now().Unix())) if err != nil { return nil, storyErr(err) } - return tgRecentStories(alignStoryRecentByPeer(peers, recent)), nil + aligned := tgRecentStories(alignStoryRecentByPeer(peers, recent)) + for i, position := range positions { + result[position] = aligned[i] + } + return result, nil } func alignStoryRecentByPeer(peers []domain.Peer, recent []domain.RecentStory) []domain.RecentStory { diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index a91be7b0..c98c8578 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -45,6 +45,15 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser addDomainPeerRef(peer, 0, userIDs, channelIDs) } collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs) + if message := out[i].EphemeralMessage; message != nil { + collectEphemeralMessagePeerRefs(*message, userIDs, channelIDs) + if message.BotAPIReply != nil { + collectEphemeralMessagePeerRefs(*message.BotAPIReply, userIDs, channelIDs) + } + } + if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 { + userIDs[out[i].BotCallbackQuery.UserID] = struct{}{} + } removeKnownChannelRefs(channelIDs, out[i].Channels) refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs} for id := range userIDs { @@ -63,6 +72,24 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser return out } +func collectEphemeralMessagePeerRefs(message domain.EphemeralMessage, userIDs, channelIDs map[int64]struct{}) { + if message.SenderUserID != 0 { + userIDs[message.SenderUserID] = struct{}{} + } + if message.ReceiverUserID != 0 { + userIDs[message.ReceiverUserID] = struct{}{} + } + addDomainPeerRef(message.Peer, 0, userIDs, channelIDs) + for _, entity := range message.Content.Entities { + if entity.UserID != 0 { + userIDs[entity.UserID] = struct{}{} + } + } + if message.Content.Media != nil && message.Content.Media.Contact != nil && message.Content.Media.Contact.UserID != 0 { + userIDs[message.Content.Media.Contact.UserID] = struct{}{} + } +} + type updateEventPeerRefs struct { userIDs map[int64]struct{} channelIDs map[int64]struct{} @@ -220,6 +247,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { userIDs[msg.Media.Contact.UserID] = struct{}{} } + if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil { + for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers { + addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs) + } + } collectPollMediaUserRefs(msg.Media, userIDs) collectTodoMediaUserRefs(msg.Media, userIDs) if msg.Reactions != nil { diff --git a/internal/rpc/updates_rpc_test.go b/internal/rpc/updates_rpc_test.go index fb9e6da3..a0dfd175 100644 --- a/internal/rpc/updates_rpc_test.go +++ b/internal/rpc/updates_rpc_test.go @@ -215,7 +215,7 @@ func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) { if update.Popup || update.InboxDate == 0 || update.Media == nil { t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media) } - for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} { + for _, want := range []string{"New login.", "Test User", "Telesrv Desktop", "Settings > Devices"} { if !strings.Contains(update.Message, want) { t.Fatalf("notification message %q missing %q", update.Message, want) } diff --git a/internal/rpc/users.go b/internal/rpc/users.go index 34f29418..bfdc0857 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -629,7 +629,11 @@ func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) t if len(profile.Commands) > 0 { cmds := make([]tg.BotCommand, 0, len(profile.Commands)) for _, c := range profile.Commands { - cmds = append(cmds, tg.BotCommand{Command: c.Command, Description: c.Description}) + cmds = append(cmds, tg.BotCommand{ + Command: c.Command, + Description: c.Description, + Ephemeral: c.Ephemeral, + }) } info.SetCommands(cmds) } diff --git a/internal/rpc/users_bot_info_test.go b/internal/rpc/users_bot_info_test.go new file mode 100644 index 00000000..d667adac --- /dev/null +++ b/internal/rpc/users_bot_info_test.go @@ -0,0 +1,25 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestTGBotInfoPreservesEphemeralCommandMarker(t *testing.T) { + got := tgBotInfoFromProfile(42, domain.BotProfile{ + Commands: []domain.BotCommand{ + {Command: "public", Description: "visible everywhere"}, + {Command: "private", Description: "Layer 228 only", Ephemeral: true}, + }, + }, true) + if len(got.Commands) != 2 { + t.Fatalf("commands = %+v, want two", got.Commands) + } + if got.Commands[0].Ephemeral { + t.Fatalf("public command = %+v, want ephemeral=false", got.Commands[0]) + } + if !got.Commands[1].Ephemeral { + t.Fatalf("private command = %+v, want ephemeral=true", got.Commands[1]) + } +} diff --git a/internal/seed/appearance/default_appearance_seed.json b/internal/seed/appearance/default_appearance_seed.json index 722aa782..db07db84 100644 --- a/internal/seed/appearance/default_appearance_seed.json +++ b/internal/seed/appearance/default_appearance_seed.json @@ -1,5 +1,5 @@ { - "source": "official telegram (appearancefetch)", + "source": "upstream appearance snapshot", "exported_at": "2026-06-25T14:04:03Z", "notes": { "server": "official", @@ -3788,7 +3788,7 @@ }, { "kind": "filename", - "file_name": "background telegram 003.jpg" + "file_name": "background telesrv 003.jpg" } ], "thumbs": [ @@ -5834,4 +5834,4 @@ } } ] -} \ No newline at end of file +} diff --git a/internal/store/account_lifecycle.go b/internal/store/account_lifecycle.go new file mode 100644 index 00000000..cfa0d2ac --- /dev/null +++ b/internal/store/account_lifecycle.go @@ -0,0 +1,22 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// AccountLifecycleStore owns the atomic boundary between tombstoning a user, +// purging private account state, revoking authorizations and enqueueing +// non-pts updateUser notifications. +type AccountLifecycleStore interface { + AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) + ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) + PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) + ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) + CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) + DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) + ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) + CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error +} diff --git a/internal/store/bot_callback.go b/internal/store/bot_callback.go new file mode 100644 index 00000000..cfe422bc --- /dev/null +++ b/internal/store/bot_callback.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// BotCallbackPending is the short-lived, protocol-neutral ownership record for +// one messages.getBotCallbackAnswer request. It is deliberately ephemeral: the +// durable Bot API update remains in BotAPIUpdateStore, while this record only +// coordinates the synchronous client answer across server instances. +type BotCallbackPending struct { + QueryID int64 + BotUserID int64 + UserID int64 + CreatedAt time.Time +} + +type BotCallbackAnswerPush struct { + QueryID int64 + BotUserID int64 + Answer domain.BotCallbackAnswer +} + +// BotCallbackRegistryStore coordinates callback waiters across processes. +// Implementations must make Put and Resolve atomic: query ids cannot be +// overwritten and at most one answer may win for the owning bot. +type BotCallbackRegistryStore interface { + PutBotCallbackPending(ctx context.Context, pending BotCallbackPending, ttl time.Duration) (bool, error) + ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) + GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) + DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error + SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, BotCallbackAnswerPush)) error +} diff --git a/internal/store/botapi_update.go b/internal/store/botapi_update.go index c76a9a33..0aa13cac 100644 --- a/internal/store/botapi_update.go +++ b/internal/store/botapi_update.go @@ -2,14 +2,40 @@ package store import ( "context" + "time" "telesrv/internal/domain" ) +// BotAPIPollLeaseStore serializes getUpdates across all HTTP gateway +// instances. owner is an opaque per-request token; Release must be compare-and- +// delete so a stale request cannot release a successor's lease. +type BotAPIPollLeaseStore interface { + AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error +} + +// BotAPIWebhookStore coordinates durable webhook configuration and delivery +// leases. Delivery itself stays at the HTTP edge; this store only owns state. +type BotAPIWebhookStore interface { + SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error + DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error + BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) + ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) + AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error + RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error + RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error +} + // BotAPIUpdateStore persists update_id based Bot API delivery queues. type BotAPIUpdateStore interface { EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) + ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, error) + SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error + DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error + PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) } diff --git a/internal/store/channel.go b/internal/store/channel.go index 44aa0553..eeacd65f 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -36,6 +36,7 @@ type ChannelStore interface { UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) // ResolvePublicChannelUsername resolves an active public channel/supergroup. diff --git a/internal/store/code.go b/internal/store/code.go index 034e0bf8..08355a7e 100644 --- a/internal/store/code.go +++ b/internal/store/code.go @@ -10,6 +10,7 @@ import ( const ( PhoneCodePurposeChangePhone = "change_phone" + PhoneCodePurposeConfirmPhone = "confirm_phone" PhoneCodeChannelPhone = "phone" PhoneCodeChannelSMS = "sms" PhoneCodeChannelEmailLogin = "email_login" @@ -75,6 +76,10 @@ type PhoneCode struct { VerifiedEmail bool RequireSignUp bool LoginEmailHash string + // AccountDeletionHash is the hex-encoded SHA-256 digest of the validated + // confirmphone link token. It binds account.confirmPhone to one pending + // deletion without persisting the raw link credential in the code record. + AccountDeletionHash string } type PhoneCodeSnapshot struct { diff --git a/internal/store/community.go b/internal/store/community.go new file mode 100644 index 00000000..aff5c7fb --- /dev/null +++ b/internal/store/community.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// CommunityStore persists the Layer 228 Community aggregate. Mutations that +// touch a link and the linked peer's denormalized linked_community_id must be +// atomic in durable implementations. +type CommunityStore interface { + CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) + GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) + GetCommunities(ctx context.Context, viewerUserID int64, communityIDs []int64) ([]domain.CommunityView, error) + ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) + ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) + SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) + ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) + DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) + DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) + ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) + GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) + ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) + EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) + EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) + EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) + EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) + SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) + DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) + SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (changed bool, err error) + ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (changed bool, err error) + CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) +} diff --git a/internal/store/ephemeral.go b/internal/store/ephemeral.go new file mode 100644 index 00000000..01ba43c9 --- /dev/null +++ b/internal/store/ephemeral.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// EphemeralMessageStore is the short-lived authoritative state used for +// idempotency, callback, edit, delete and report lookups. Implementations must +// make Create atomic across the message ID and random-ID indexes. +type EphemeralMessageStore interface { + CreateEphemeralMessage(ctx context.Context, message domain.EphemeralMessage) (stored domain.EphemeralMessage, created bool, err error) + GetEphemeralMessage(ctx context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) + EditEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) + DeleteEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) + PruneExpiredEphemeralMessages(ctx context.Context, now time.Time, limit int) (int, error) + PutEphemeralCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) + GetEphemeralCallbackAction(ctx context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) +} + +// EphemeralReportStore is deliberately durable: transient messages disappear +// after 48 hours, while a submitted abuse report must retain review evidence. +type EphemeralReportStore interface { + CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (created bool, err error) +} + +type EphemeralPushKind string + +const ( + EphemeralPushNew EphemeralPushKind = "new" + EphemeralPushEdit EphemeralPushKind = "edit" + EphemeralPushDelete EphemeralPushKind = "delete" + EphemeralPushCallback EphemeralPushKind = "callback" +) + +// EphemeralPush is a process-to-process online accelerator. It is deliberately +// not a durable event: Redis Pub/Sub and ready Layer 228 sessions are the only +// consumers, while EphemeralMessageStore remains the short-lived lookup truth. +type EphemeralPush struct { + SourceID string + Kind EphemeralPushKind + TargetUserID int64 + TargetBusinessAuthKey [8]byte + Message domain.EphemeralMessage + Callback *domain.BotCallbackQuery + Date int +} + +type EphemeralPushBroker interface { + PublishEphemeralPush(ctx context.Context, event EphemeralPush) error + SubscribeEphemeralPushes(ctx context.Context, handle func(context.Context, EphemeralPush)) error +} diff --git a/internal/store/memory/botapi_update.go b/internal/store/memory/botapi_update.go index 1703fda4..673c3385 100644 --- a/internal/store/memory/botapi_update.go +++ b/internal/store/memory/botapi_update.go @@ -3,29 +3,228 @@ package memory import ( "context" "fmt" + "slices" "sync" + "time" "telesrv/internal/domain" ) // BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore. type BotAPIUpdateStore struct { - mu sync.RWMutex - nextID int64 - rows []domain.BotAPIUpdate - state map[int64]int64 - byKey map[string]int64 + mu sync.RWMutex + nextID int64 + rows []domain.BotAPIUpdate + state map[int64]int64 + cursorInitialized map[int64]bool + allowed map[int64]map[domain.BotAPIUpdateKind]struct{} + byKey map[string]int64 + pollLeases map[int64]botAPIPollLease + webhooks map[int64]domain.BotAPIWebhook + webhookLeases map[int64]botAPIPollLease +} + +type botAPIPollLease struct { + owner string + expiresAt time.Time } // NewBotAPIUpdateStore creates an in-memory Bot API update queue. func NewBotAPIUpdateStore() *BotAPIUpdateStore { return &BotAPIUpdateStore{ - nextID: 1, - state: make(map[int64]int64), - byKey: make(map[string]int64), + nextID: 1, + state: make(map[int64]int64), + cursorInitialized: make(map[int64]bool), + allowed: make(map[int64]map[domain.BotAPIUpdateKind]struct{}), + byKey: make(map[string]int64), + pollLeases: make(map[int64]botAPIPollLease), + webhooks: make(map[int64]domain.BotAPIWebhook), + webhookLeases: make(map[int64]botAPIPollLease), } } +func (s *BotAPIUpdateStore) SetBotAPIWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error { + if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 { + return fmt.Errorf("invalid bot api webhook") + } + s.mu.Lock() + defer s.mu.Unlock() + if !config.AllowedUpdatesSet { + config.AllowedUpdates = allowedUpdateKinds(s.allowed[config.BotUserID]) + } + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, "" + config.NextAttemptAt = time.Now() + s.webhooks[config.BotUserID] = config + if config.AllowedUpdates == nil { + delete(s.allowed, config.BotUserID) + } else { + allowed := make(map[domain.BotAPIUpdateKind]struct{}, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + allowed[kind] = struct{}{} + } + s.allowed[config.BotUserID] = allowed + } + if dropPending { + s.dropPendingLocked(config.BotUserID) + } + return nil +} + +func allowedUpdateKinds(items map[domain.BotAPIUpdateKind]struct{}) []domain.BotAPIUpdateKind { + if len(items) == 0 { + return nil + } + out := make([]domain.BotAPIUpdateKind, 0, len(items)) + for kind := range items { + out = append(out, kind) + } + slices.Sort(out) + return out +} + +func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(_ context.Context, botUserID int64, dropPending bool) error { + s.mu.Lock() + delete(s.webhooks, botUserID) + delete(s.webhookLeases, botUserID) + if dropPending { + s.dropPendingLocked(botUserID) + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) BotAPIWebhook(_ context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) { + s.mu.RLock() + config, found := s.webhooks[botUserID] + s.mu.RUnlock() + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + return config, found, nil +} + +func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(_ context.Context, limit int) ([]domain.BotAPIWebhook, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + now := time.Now() + s.mu.RLock() + out := make([]domain.BotAPIWebhook, 0, min(limit, len(s.webhooks))) + for botID, config := range s.webhooks { + lease := s.webhookLeases[botID] + if config.NextAttemptAt.After(now) || (lease.owner != "" && lease.expiresAt.After(now)) { + continue + } + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + out = append(out, config) + if len(out) == limit { + break + } + } + s.mu.RUnlock() + return out, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api webhook lease") + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if _, found := s.webhooks[botUserID]; !found { + return false, nil + } + current := s.webhookLeases[botUserID] + if current.owner != "" && current.owner != owner && current.expiresAt.After(now) { + return false, nil + } + s.webhookLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)} + return true, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(_ context.Context, botUserID int64, owner string) error { + s.mu.Lock() + if current := s.webhookLeases[botUserID]; current.owner == owner { + delete(s.webhookLeases, botUserID) + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(_ context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error { + s.mu.Lock() + defer s.mu.Unlock() + if current := s.webhookLeases[botUserID]; current.owner != owner { + return nil + } + config, found := s.webhooks[botUserID] + if !found { + delete(s.webhookLeases, botUserID) + return nil + } + config.FailureCount++ + config.LastErrorDate = int(time.Now().Unix()) + if len(message) > 512 { + message = message[:512] + } + config.LastErrorMessage = message + config.NextAttemptAt = nextAttempt + s.webhooks[botUserID] = config + delete(s.webhookLeases, botUserID) + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(_ context.Context, botUserID int64, owner string, nextAttempt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if current := s.webhookLeases[botUserID]; current.owner != owner { + return nil + } + if config, found := s.webhooks[botUserID]; found { + config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, "" + config.NextAttemptAt = nextAttempt + s.webhooks[botUserID] = config + } + delete(s.webhookLeases, botUserID) + return nil +} + +func (s *BotAPIUpdateStore) dropPendingLocked(botUserID int64) { + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > s.state[botUserID] { + s.state[botUserID] = row.ID + } + } + s.cursorInitialized[botUserID] = true +} + +func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api poll lease") + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + current, found := s.pollLeases[botUserID] + if found && current.owner != owner && current.expiresAt.After(now) { + return false, nil + } + s.pollLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)} + return true, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(_ context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + s.mu.Lock() + if current, found := s.pollLeases[botUserID]; found && current.owner == owner { + delete(s.pollLeases, botUserID) + } + s.mu.Unlock() + return nil +} + func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) { if err := validateBotAPIUpdateRequest(req); err != nil { return domain.BotAPIUpdate{}, false, err @@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En key := botAPIUpdateKey(req) s.mu.Lock() defer s.mu.Unlock() + if allowed, configured := s.allowed[req.BotUserID]; configured { + if _, ok := allowed[req.Kind]; !ok { + return domain.BotAPIUpdate{}, false, nil + } + } if existingID, ok := s.byKey[key]; ok { for _, row := range s.rows { if row.ID == existingID { @@ -48,13 +252,57 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En MessageID: req.MessageID, SourcePts: req.SourcePts, Date: req.Date, + Callback: cloneBotAPICallback(req.Callback), + Ephemeral: cloneBotAPIEphemeral(req.Ephemeral), } s.nextID++ s.rows = append(s.rows, row) s.byKey[key] = row.ID + if config, found := s.webhooks[req.BotUserID]; found { + config.NextAttemptAt = time.Now() + s.webhooks[req.BotUserID] = config + } return cloneBotAPIUpdate(row), true, nil } +func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(_ context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) { + if botUserID == 0 || tail <= 0 { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 100 + } + s.mu.RLock() + defer s.mu.RUnlock() + confirmed := s.state[botUserID] + matching := make([]domain.BotAPIUpdate, 0, min(tail, limit)) + start := 0 + count := 0 + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > confirmed { + count++ + } + } + if count > tail { + start = count - tail + } + seen := 0 + for _, row := range s.rows { + if row.BotUserID != botUserID || row.ID <= confirmed { + continue + } + if seen < start { + seen++ + continue + } + matching = append(matching, cloneBotAPIUpdate(row)) + if len(matching) >= limit { + break + } + } + return matching, nil +} + func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) { if botUserID == 0 { return nil, nil @@ -85,13 +333,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c return nil } s.mu.Lock() + maxExisting := int64(0) + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > maxExisting { + maxExisting = row.ID + } + } + if confirmedUpdateID > maxExisting && s.cursorInitialized[botUserID] { + s.mu.Unlock() + return nil + } + if confirmedUpdateID > maxExisting { + confirmedUpdateID = maxExisting + } if confirmedUpdateID > s.state[botUserID] { s.state[botUserID] = confirmedUpdateID } + s.cursorInitialized[botUserID] = true s.mu.Unlock() return nil } +func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(_ context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error { + if botUserID == 0 { + return nil + } + s.mu.Lock() + if len(allowed) == 0 { + delete(s.allowed, botUserID) + } else { + set := make(map[domain.BotAPIUpdateKind]struct{}, len(allowed)) + for _, kind := range allowed { + if kind != "" { + set[kind] = struct{}{} + } + } + s.allowed[botUserID] = set + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error { + if botUserID == 0 { + return nil + } + s.mu.Lock() + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > s.state[botUserID] { + s.state[botUserID] = row.ID + } + } + s.cursorInitialized[botUserID] = true + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(_ context.Context, botUserID int64) (int, error) { + if botUserID == 0 { + return 0, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + confirmed := s.state[botUserID] + count := 0 + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > confirmed { + count++ + } + } + return count, nil +} + func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) { if botUserID == 0 { return 0, false, nil @@ -103,27 +416,87 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID } func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { - if req.BotUserID == 0 || req.MessageID <= 0 { + if req.BotUserID == 0 { return fmt.Errorf("invalid bot api update") } - if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage { + if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery { return fmt.Errorf("invalid bot api update kind %q", req.Kind) } switch req.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: - if req.Peer.ID <= 0 { + if req.Peer.ID <= 0 || req.MessageID <= 0 { + return fmt.Errorf("invalid bot api update peer") + } + case "": + if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 { return fmt.Errorf("invalid bot api update peer") } default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if req.Ephemeral != nil { + message := req.Ephemeral.Message + if req.Ephemeral.Validate() != nil || message.ID != req.MessageID || message.Peer != req.Peer || message.Expired(time.Unix(int64(req.Date), 0)) || + req.Peer.Type != domain.PeerTypeChannel || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api ephemeral update") + } + if (req.Kind == domain.BotAPIUpdateCallbackQuery && message.SenderUserID != req.BotUserID) || + (req.Kind != domain.BotAPIUpdateCallbackQuery && message.ReceiverUserID != req.BotUserID) { + return fmt.Errorf("invalid bot api ephemeral target") + } + } + if req.Kind == domain.BotAPIUpdateCallbackQuery { + cb := req.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || + cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 || + len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api callback query") + } + inline := cb.InlineMessage + if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) { + return fmt.Errorf("invalid bot api inline callback query") + } + if req.MessageID > 0 && inline != nil { + return fmt.Errorf("ambiguous bot api callback query") + } + } else if req.Callback != nil { + return fmt.Errorf("unexpected bot api callback query") + } return nil } func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string { + if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil { + return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID) + } + if req.Ephemeral != nil { + return fmt.Sprintf("%d:%s:ephemeral:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.Ephemeral.Message.Version) + } return fmt.Sprintf("%d:%s:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.SourcePts) } func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate { + row.Callback = cloneBotAPICallback(row.Callback) + row.Ephemeral = cloneBotAPIEphemeral(row.Ephemeral) return row } + +func cloneBotAPIEphemeral(in *domain.BotAPIEphemeralPayload) *domain.BotAPIEphemeralPayload { + if in == nil { + return nil + } + return domain.NewBotAPIEphemeralPayload(cloneEphemeralMessage(in.EphemeralMessage())) +} + +func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery { + if in == nil { + return nil + } + out := *in + out.Data = append([]byte(nil), in.Data...) + if in.InlineMessage != nil { + inline := *in.InlineMessage + out.InlineMessage = &inline + } + return &out +} diff --git a/internal/store/memory/botapi_update_test.go b/internal/store/memory/botapi_update_test.go new file mode 100644 index 00000000..6c9df57c --- /dev/null +++ b/internal/store/memory/botapi_update_test.go @@ -0,0 +1,245 @@ +package memory + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +func botAPIMessageRequest(botID int64, kind domain.BotAPIUpdateKind, messageID int) domain.EnqueueBotAPIUpdateRequest { + return domain.EnqueueBotAPIUpdateRequest{ + BotUserID: botID, + Kind: kind, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + MessageID: messageID, + SourcePts: messageID, + Date: 1700000000 + messageID, + } +} + +func TestBotAPIPollLeaseCompareOwnerAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "one", 20*time.Millisecond); err != nil || !acquired { + t.Fatalf("first acquire=%v err=%v", acquired, err) + } + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || acquired { + t.Fatalf("competing acquire=%v err=%v", acquired, err) + } + if err := store.ReleaseBotAPIPollLease(ctx, 1001, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); acquired { + t.Fatal("stale release removed active owner") + } + time.Sleep(25 * time.Millisecond) + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || !acquired { + t.Fatalf("expired acquire=%v err=%v", acquired, err) + } +} + +func TestBotAPIWebhookLeaseWakeAndAtomicDrop(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || !created { + t.Fatalf("enqueue initial created=%v err=%v", created, err) + } + config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/hook", MaxConnections: 8} + if err := store.SetBotAPIWebhook(ctx, config, true); err != nil { + t.Fatal(err) + } + if count, _ := store.PendingBotAPIUpdateCount(ctx, 1001); count != 0 { + t.Fatalf("pending after atomic drop=%d", count) + } + if acquired, err := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-1", time.Second); err != nil || !acquired { + t.Fatalf("lease acquire=%v err=%v", acquired, err) + } + if acquired, _ := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-2", time.Second); acquired { + t.Fatal("second webhook worker acquired active lease") + } + if err := store.RecordBotAPIWebhookSuccess(ctx, 1001, "worker-1", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 { + t.Fatalf("idle due=%#v err=%v", due, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created { + t.Fatalf("enqueue wake created=%v err=%v", created, err) + } + if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != 1001 { + t.Fatalf("woken due=%#v err=%v", due, err) + } +} + +func TestBotAPIWebhookAllowedUpdatesOmissionPreservesPolicy(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}); err != nil { + t.Fatal(err) + } + config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/one", MaxConnections: 8} + if err := store.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err := store.BotAPIWebhook(ctx, 1001) + if err != nil || !found || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery { + t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err) + } + if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || created || row.ID != 0 { + t.Fatalf("message bypassed preserved policy: row=%#v created=%v err=%v", row, created, err) + } + config.URL = "https://example.test/two" + config.AllowedUpdatesSet = true // Explicit empty resets to the default/all policy. + if err := store.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err = store.BotAPIWebhook(ctx, 1001) + if err != nil || !found || stored.AllowedUpdates != nil { + t.Fatalf("explicit empty webhook=%#v found=%v err=%v", stored, found, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created { + t.Fatalf("message after explicit reset created=%v err=%v", created, err) + } +} + +func TestBotAPIUpdateCursorClampDropAndTail(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + for id := 1; id <= 5; id++ { + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, id)); err != nil || !created { + t.Fatalf("enqueue %d: created=%v err=%v", id, created, err) + } + } + tail, err := store.ListTailBotAPIUpdates(ctx, 1001, 2, 100) + if err != nil || len(tail) != 2 || tail[0].MessageID != 4 || tail[1].MessageID != 5 { + t.Fatalf("tail = %#v err=%v", tail, err) + } + if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil { + t.Fatalf("confirm huge offset: %v", err) + } + confirmed, found, err := store.ConfirmedBotAPIUpdateID(ctx, 1001) + if err != nil || !found || confirmed != 5 { + t.Fatalf("confirmed = %d found=%v err=%v, want 5", confirmed, found, err) + } + row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 6)) + if err != nil || !created { + t.Fatalf("enqueue after huge offset: row=%#v created=%v err=%v", row, created, err) + } + if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil { + t.Fatalf("repeat foreign offset: %v", err) + } + if confirmed, _, _ := store.ConfirmedBotAPIUpdateID(ctx, 1001); confirmed != 5 { + t.Fatalf("repeat foreign offset advanced cursor to %d, want 5", confirmed) + } + pending, err := store.ListBotAPIUpdates(ctx, 1001, confirmed+1, 100) + if err != nil || len(pending) != 1 || pending[0].MessageID != 6 { + t.Fatalf("pending after huge offset = %#v err=%v", pending, err) + } + if err := store.DropPendingBotAPIUpdates(ctx, 1001); err != nil { + t.Fatalf("drop pending: %v", err) + } + count, err := store.PendingBotAPIUpdateCount(ctx, 1001) + if err != nil || count != 0 { + t.Fatalf("pending count = %d err=%v", count, err) + } +} + +func TestBotAPIAllowedUpdatesOnlyAffectsFutureEnqueue(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + first, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)) + if err != nil || !created { + t.Fatalf("enqueue pre-policy: %#v created=%v err=%v", first, created, err) + } + if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil { + t.Fatalf("set policy: %v", err) + } + if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || created || row.ID != 0 { + t.Fatalf("filtered message = %#v created=%v err=%v", row, created, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateEditedMessage, 3)); err != nil || !created { + t.Fatalf("allowed edit created=%v err=%v", created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100) + if err != nil || len(rows) != 2 || rows[0].ID != first.ID || rows[1].Kind != domain.BotAPIUpdateEditedMessage { + t.Fatalf("rows = %#v err=%v", rows, err) + } +} + +func TestBotAPIInlineCallbackRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + callback := &domain.BotCallbackQuery{ + ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, Data: []byte("tap"), + InlineMessage: &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 15, AccessHash: 1234}, + } + row, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback, + }) + if err != nil || !created || row.MessageID != 0 || row.Peer != (domain.Peer{}) || row.Callback == nil || + row.Callback.InlineMessage == nil || *row.Callback.InlineMessage != *callback.InlineMessage { + t.Fatalf("inline callback row=%#v created=%v err=%v", row, created, err) + } + callback.Data[0] = 'X' + callback.InlineMessage.ID = 99 + rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100) + if err != nil || len(rows) != 1 || string(rows[0].Callback.Data) != "tap" || rows[0].Callback.InlineMessage.ID != 15 { + t.Fatalf("inline callback rows=%#v err=%v", rows, err) + } +} + +func TestBotAPIEphemeralMessageVersionsAndCallbackRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001} + incoming := domain.EphemeralMessage{ + ID: 71, Peer: peer, SenderUserID: 2001, ReceiverUserID: 1001, + Date: int(now.Unix()), RandomID: 1, Content: domain.EphemeralContent{Message: "/private"}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + request := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateMessage, Peer: peer, + MessageID: incoming.ID, Date: incoming.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(incoming), + } + first, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || first.SourcePts != 0 || first.Ephemeral == nil { + t.Fatalf("first=%+v created=%v err=%v", first, created, err) + } + if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID { + t.Fatalf("replay=%+v created=%v err=%v", replay, created, err) + } + incoming.Version = 2 + incoming.EditDate = incoming.Date + 1 + incoming.Content.Message = "edited" + request.Kind = domain.BotAPIUpdateEditedMessage + request.Ephemeral = domain.NewBotAPIEphemeralPayload(incoming) + edited, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || edited.ID <= first.ID { + t.Fatalf("edited=%+v created=%v err=%v", edited, created, err) + } + + outgoing := incoming + outgoing.ID, outgoing.SenderUserID, outgoing.ReceiverUserID = 72, 1001, 2001 + outgoing.Version, outgoing.Content.Message = 1, "button" + callback := &domain.BotCallbackQuery{ + ID: 9001, BotUserID: 1001, UserID: 2001, Peer: peer, + MessageID: outgoing.ID, ChatInstance: 901, Data: []byte("tap"), + } + callbackRow, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Peer: peer, + MessageID: outgoing.ID, Date: outgoing.Date, Callback: callback, + Ephemeral: domain.NewBotAPIEphemeralPayload(outgoing), + }) + if err != nil || !created || callbackRow.Callback == nil || callbackRow.Ephemeral == nil { + t.Fatalf("callback=%+v created=%v err=%v", callbackRow, created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, 1001, first.ID, 100) + if err != nil || len(rows) != 3 || rows[0].Ephemeral.Message.Content.Message != "/private" || + rows[1].Ephemeral.Message.Content.Message != "edited" || string(rows[2].Callback.Data) != "tap" { + t.Fatalf("rows=%+v err=%v", rows, err) + } +} diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 515df5c9..bda20e6c 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -290,6 +290,12 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af } out = append(out, channelID) } + for channelID, channel := range s.channels { + if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) || containsInt64(out, channelID) { + continue + } + out = append(out, channelID) + } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) if len(out) > limit { out = out[:limit] @@ -324,6 +330,24 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) } } + for channelID, channel := range s.channels { + if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) { + continue + } + checkpoint := s.channelUpdateCheckpointLocked(channelID, channel) + if checkpoint.LatestEventDate > sinceDate { + found := false + for _, item := range out { + if item.ChannelID == channelID { + found = true + break + } + } + if !found { + out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) + } + } + } sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) if len(out) > limit { out = out[:limit] @@ -331,6 +355,34 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID return out, nil } +func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID int64) bool { + if userID == 0 || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { + return false + } + parent, ok := s.channels[mono.LinkedMonoforumID] + if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID { + return false + } + if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) { + return true + } + for _, msg := range s.messages[mono.ID] { + if !msg.Deleted && msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return true + } + } + return false +} + +func containsInt64(items []int64, target int64) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + func (s *ChannelStore) nextChannelIDLocked() int64 { id := s.nextID s.nextID++ @@ -369,6 +421,10 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) { return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil } + parent, ok := s.channels[channel.LinkedMonoforumID] + if ok && !parent.Deleted && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID { + return channel, syntheticMonoforumUserMember(channel, userID), true, nil + } } if !publicPreviewableChannel(channel) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate diff --git a/internal/store/memory/channel_invites.go b/internal/store/memory/channel_invites.go index 1b763401..f708cbba 100644 --- a/internal/store/memory/channel_invites.go +++ b/internal/store/memory/channel_invites.go @@ -17,6 +17,9 @@ func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUser if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } inviter := s.members[channelID][inviterUserID] if !canInviteToChannel(channel, inviter) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index f0d4cf48..3aec3389 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -109,6 +109,9 @@ func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, d if !ok || channel.Deleted { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } preJoinTopID := channel.TopMessageID if existing, ok := s.members[channelID][userID]; ok { if existing.Status == domain.ChannelMemberActive { @@ -652,6 +655,31 @@ func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64 return append([]domain.Channel(nil), out...), nil } +func (s *ChannelStore) ListCommunityLinkableChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || channel.Monoforum || channel.LinkedCommunityID != 0 { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxCommunityPeers { + out = out[:domain.MaxCommunityPeers] + } + return append([]domain.Channel(nil), out...), nil +} + func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) { if userID == 0 { return nil, nil @@ -1050,6 +1078,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan return member } +func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: mono.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + } +} + func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) { if !publicSearchableChannel(channel) { return 0, false diff --git a/internal/store/memory/channel_message_helpers.go b/internal/store/memory/channel_message_helpers.go index 2b9fb3a8..d42ea089 100644 --- a/internal/store/memory/channel_message_helpers.go +++ b/internal/store/memory/channel_message_helpers.go @@ -34,6 +34,14 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Replies = cloneChannelMessageReplies(in.Replies) in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) + if in.SuggestedPost != nil { + suggested := *in.SuggestedPost + if suggested.Price != nil { + price := *suggested.Price + suggested.Price = &price + } + in.SuggestedPost = &suggested + } if in.SendAs != nil { p := *in.SendAs in.SendAs = &p diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index 824033dd..0a881f6d 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -24,12 +24,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, // 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。 query := strings.ToLower(strings.TrimSpace(filter.Query)) matched := make([]domain.ChannelMessage, 0, len(items)) + monoforumUserView := channel.Monoforum && !isChannelAdmin(member) for _, msg := range items { if msg.Deleted { continue } - if channel.Monoforum && msg.SavedPeer.ID != 0 { - continue + if channel.Monoforum { + if monoforumUserView && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) { + continue + } + if !monoforumUserView && msg.SavedPeer.ID != 0 { + continue + } } if msg.ID <= member.AvailableMinID { continue @@ -186,7 +192,16 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 s.mu.RLock() defer s.mu.RUnlock() hits := make([]hit, 0, req.Limit+1) + channelIDs := make(map[int64]struct{}, len(req.ChannelIDs)) + for _, id := range req.ChannelIDs { + channelIDs[id] = struct{}{} + } for channelID, channel := range s.channels { + if req.RestrictChannelIDs { + if _, ok := channelIDs[channelID]; !ok { + continue + } + } if channel.Deleted { continue } @@ -197,7 +212,10 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 continue } member, ok := s.members[channelID][viewerUserID] - if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages + publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) && + (!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages) + if !joined && !publicPreview { continue } if req.HasFolderID { @@ -213,7 +231,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 if query == "" && !req.MusicOnly || query != "" && strings.TrimSpace(msg.Body) == "" { continue } - if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { + if joined && member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { continue } if req.MinDate > 0 && msg.Date <= req.MinDate { diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go index b5ebcd7c..e8138569 100644 --- a/internal/store/memory/channel_message_send.go +++ b/internal/store/memory/channel_message_send.go @@ -316,13 +316,21 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla Message: cloneChannelMessage(replay), SenderUserID: first.SenderUserID, } - return domain.SendChannelMessageResult{ + result := domain.SendChannelMessageResult{ Channel: cloneChannel(channel), Message: cloneChannelMessage(replay), Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete, - }, true, nil + } + if first.PaidMessageStars > 0 { + balance, ok := s.starsBalances[first.SenderUserID] + if !ok { + return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory paid-message replay has no sender balance") + } + result.SenderStarsBalance = &domain.StarsBalance{UserID: first.SenderUserID, Balance: balance, Granted: true} + } + return result, true, nil } func channelDeliverySkipSet(ids []int64) map[int64]struct{} { diff --git a/internal/store/memory/channel_monoforum.go b/internal/store/memory/channel_monoforum.go index 077f8190..8634f7dc 100644 --- a/internal/store/memory/channel_monoforum.go +++ b/internal/store/memory/channel_monoforum.go @@ -10,13 +10,19 @@ import ( "telesrv/internal/store" ) +const paidMessageChannelCommissionPermille int64 = 850 + // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 -// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。 +// 与 postgres 行为一致:复用 channel pts/事件;订阅者无需成员记录且只能写自己的 saved_peer, +// 母频道管理员可以回复任意订阅者。 func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || - req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if req.AllowPaidStars < 0 { + return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount + } var fingerprint []byte var err error if req.RandomID != 0 { @@ -43,23 +49,81 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo if !ok || channel.Deleted || !channel.Monoforum { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + parent, ok := s.channels[channel.LinkedMonoforumID] + if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != channel.ID { + return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate + } + parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID] + isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) + if req.SenderUserID != req.SavedPeer.ID && !isAdmin { + return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired + } + if req.ReplyTo != nil { + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + found := false + for _, candidate := range s.messages[channel.ID] { + if candidate.ID == req.ReplyTo.MessageID && !candidate.Deleted && candidate.SavedPeer == req.SavedPeer { + found = true + break + } + } + if !found { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + } + var senderBalance *domain.StarsBalance + paidMessageStars := int64(0) + balanceAfter := int64(0) + if !isAdmin && channel.SendPaidMessagesStars > 0 { + if channel.SendPaidMessagesStars != parent.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.AllowPaidStars < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars} + } + current, ok := s.starsBalances[req.SenderUserID] + if !ok { + current = domain.DefaultStarsStartingGrant + } + if current < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + paidMessageStars = channel.SendPaidMessagesStars + balanceAfter = current - paidMessageStars + senderBalance = &domain.StarsBalance{UserID: req.SenderUserID, Balance: balanceAfter, Granted: true} + } + from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + if isAdmin { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID} + } if req.Date == 0 { req.Date = int(time.Now().Unix()) } pts := s.nextChannelPtsLocked(req.MonoforumID) msgID := s.nextChannelMessageIDLocked(req.MonoforumID) msg := domain.ChannelMessage{ - ChannelID: req.MonoforumID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.SenderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - SavedPeer: req.SavedPeer, - Date: req.Date, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Pts: pts, + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: from, + SavedPeer: req.SavedPeer, + SuggestedPost: req.SuggestedPost, + PaidMessageStars: paidMessageStars, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: req.ReplyTo, + Pts: pts, } + // Store owns the persisted snapshot; callers must not be able to mutate it through + // SuggestedPost/Media pointers after SendMonoforumMessage returns. + msg = cloneChannelMessage(msg) var sendSnapshot []byte if req.RandomID != 0 { var snapshotErr error @@ -78,6 +142,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo SenderUserID: req.SenderUserID, } s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg) + if paidMessageStars > 0 { + s.starsBalances[req.SenderUserID] = balanceAfter + s.channelStarsBalances[parent.ID] += paidMessageStars * paidMessageChannelCommissionPermille / 1000 + } if req.RandomID != 0 { replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID} s.sendSnapshots[replayKey] = sendSnapshot @@ -87,7 +155,13 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo channel.TopMessageID = msgID channel.Pts = pts s.channels[req.MonoforumID] = channel - return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, nil + recipients := []int64{req.SavedPeer.ID} + for userID, member := range s.members[parent.ID] { + if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) { + recipients = append(recipients, userID) + } + } + return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0), SenderStarsBalance: senderBalance}, nil } // findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。 diff --git a/internal/store/memory/channel_monoforum_send_test.go b/internal/store/memory/channel_monoforum_send_test.go index 9652db64..370883f6 100644 --- a/internal/store/memory/channel_monoforum_send_test.go +++ b/internal/store/memory/channel_monoforum_send_test.go @@ -35,11 +35,14 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) } + if !containsInt64(m1.Recipients, 1) || !containsInt64(m1.Recipients, 42) || len(m1.Recipients) != 2 { + t.Fatalf("m1 recipients = %v, want subscriber 42 + parent admin 1", m1.Recipients) + } if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil { t.Fatalf("subscriber send 2: %v", err) } // 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。 - if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil { + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_003}); err != nil { t.Fatalf("admin reply: %v", err) } @@ -58,8 +61,17 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) } - if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { - t.Fatalf("subscriber main monoforum history = nil err, want denied") + subscriberHist, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber monoforum history: %v", err) + } + if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 { + t.Fatalf("subscriber monoforum history count=%d len=%d, want 3 own messages", subscriberHist.Count, len(subscriberHist.Messages)) + } + for _, message := range subscriberHist.Messages { + if message.SavedPeer != sub { + t.Fatalf("subscriber history leaked saved_peer=%+v, want self %+v", message.SavedPeer, sub) + } } // 幂等:相同 randomID 返回原消息、不重复。 @@ -84,6 +96,12 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if hist.Messages[0].Body != "reply" { t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body) } + if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID { + t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID) + } + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err) + } for _, m := range hist.Messages { if m.SavedPeer != sub { t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) @@ -99,6 +117,40 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if subHist.Count != 3 { t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) } + subscriberChannelHistory, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber channel history after other subscriber: %v", err) + } + if subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 { + t.Fatalf("subscriber channel history after other = %d/%d, want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages)) + } + for _, message := range subscriberChannelHistory.Messages { + if message.SavedPeer != sub { + t.Fatalf("subscriber channel history leaked message %+v", message) + } + } + diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("subscriber channel difference: %v", err) + } + if diff.Pts != store.channels[monoID].Pts { + t.Fatalf("subscriber difference pts = %d, want channel pts %d despite filtered events", diff.Pts, store.channels[monoID].Pts) + } + if len(diff.NewMessages) != 3 { + t.Fatalf("subscriber difference messages = %d, want own 3", len(diff.NewMessages)) + } + for _, message := range diff.NewMessages { + if message.SavedPeer != sub { + t.Fatalf("subscriber difference leaked message %+v", message) + } + } + activeChannelIDs, err := store.ListActiveChannelIDsForUser(ctx, 42, 0, 10) + if err != nil { + t.Fatalf("subscriber active channels: %v", err) + } + if !containsInt64(activeChannelIDs, monoID) { + t.Fatalf("subscriber active channels = %v, want monoforum %d for offline recovery", activeChannelIDs, monoID) + } // 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。 a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010}) @@ -141,6 +193,13 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if err != nil { t.Fatalf("delete monoforum message: %v", err) } + deleteDiff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10}) + if err != nil { + t.Fatalf("subscriber difference after own delete: %v", err) + } + if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID { + t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts) + } ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID]) deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014}) if err != nil { @@ -153,3 +212,73 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay) } } + +func TestSendPaidMonoforumMessageLedger(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Paid DM", Broadcast: true, Date: 1_700_002_000}) + if err != nil { + t.Fatalf("create: %v", err) + } + enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 10, true) + if err != nil { + t.Fatalf("enable paid DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + baseMessages := len(store.messages[monoID]) + + low := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3001, Message: "too low", AllowPaidStars: 9, Date: 1_700_002_001} + var required *domain.StarsPaymentRequiredError + if _, err := store.SendMonoforumMessage(ctx, low); !errors.As(err, &required) || required.Stars != 10 { + t.Fatalf("low authorization err = %v, want 10-Star payment required", err) + } + if len(store.messages[monoID]) != baseMessages { + t.Fatalf("low authorization wrote a message") + } + + store.starsBalances[42] = 25 + paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3002, Message: "paid", AllowPaidStars: 99, Date: 1_700_002_002} + paid, err := store.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid send: %v", err) + } + if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance) + } + if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("ledger sender/channel = %d/%d, want 15/8", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID]) + } + + duplicate, err := store.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid replay: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ID != paid.Message.ID || duplicate.SenderStarsBalance == nil || duplicate.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid replay = %+v, want original message and balance 15", duplicate) + } + if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("paid replay double charged: sender/channel=%d/%d", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID]) + } + + admin, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 3003, Message: "free admin reply", AllowPaidStars: 100, Date: 1_700_002_003, + }) + if err != nil { + t.Fatalf("admin reply: %v", err) + } + if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("admin reply charged: message=%+v balance=%+v channel=%d", admin.Message, admin.SenderStarsBalance, store.channelStarsBalances[broadcast.Channel.ID]) + } + + store.starsBalances[99] = 5 + other := domain.Peer{Type: domain.PeerTypeUser, ID: 99} + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 3004, Message: "insufficient", AllowPaidStars: 10, Date: 1_700_002_004, + }); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err) + } + if store.starsBalances[99] != 5 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("insufficient send mutated ledger: sender/channel=%d/%d", store.starsBalances[99], store.channelStarsBalances[broadcast.Channel.ID]) + } +} diff --git a/internal/store/memory/channel_store.go b/internal/store/memory/channel_store.go index fac32d7a..abcc0033 100644 --- a/internal/store/memory/channel_store.go +++ b/internal/store/memory/channel_store.go @@ -73,27 +73,29 @@ type ChannelStore struct { messages map[int64][]domain.ChannelMessage reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction // paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。 - paidReactions map[int64]map[int]map[int64]memoryPaidReaction - top map[int64]map[string]domain.TopMessageReaction - recent map[int64]map[string]domain.RecentMessageReaction - savedTags map[int64]map[string]domain.SavedReactionTag - mentions map[int64]map[int64]map[int]memoryMention - msgViews map[int64]map[int]int - msgViewers map[int64]map[int]map[int64]struct{} - events map[int64][]domain.ChannelUpdateEvent - retention map[int64]domain.ChannelUpdateRetentionCheckpoint - adminLogs map[int64][]domain.ChannelAdminLogEvent - invites map[string]domain.ChannelInvite - importers map[int64]map[int64]domain.ChannelInviteImporter - msgSeq map[int64]int - ptsSeq map[int64]int - logSeq map[int64]int64 - randomToID map[channelRandomKey]int - sendSnapshots map[channelMessageReplayKey][]byte - sendFingerprints map[channelMessageReplayKey][]byte - deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent - boostSlots map[boostSlotKey]domain.PremiumBoostSlot - readMarks map[int64]channelReadWatermark + paidReactions map[int64]map[int]map[int64]memoryPaidReaction + top map[int64]map[string]domain.TopMessageReaction + recent map[int64]map[string]domain.RecentMessageReaction + savedTags map[int64]map[string]domain.SavedReactionTag + mentions map[int64]map[int64]map[int]memoryMention + msgViews map[int64]map[int]int + msgViewers map[int64]map[int]map[int64]struct{} + events map[int64][]domain.ChannelUpdateEvent + retention map[int64]domain.ChannelUpdateRetentionCheckpoint + adminLogs map[int64][]domain.ChannelAdminLogEvent + invites map[string]domain.ChannelInvite + importers map[int64]map[int64]domain.ChannelInviteImporter + msgSeq map[int64]int + ptsSeq map[int64]int + logSeq map[int64]int64 + randomToID map[channelRandomKey]int + sendSnapshots map[channelMessageReplayKey][]byte + sendFingerprints map[channelMessageReplayKey][]byte + deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent + starsBalances map[int64]int64 + channelStarsBalances map[int64]int64 + boostSlots map[boostSlotKey]domain.PremiumBoostSlot + readMarks map[int64]channelReadWatermark // topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。 topicReads map[int64]map[int64]map[int]memoryTopicRead // polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。 @@ -108,35 +110,37 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) { // NewChannelStore creates an in-memory ChannelStore. func NewChannelStore() *ChannelStore { return &ChannelStore{ - nextID: firstMemoryChannelID, - nextHash: 900000000000, - channels: make(map[int64]domain.Channel), - members: make(map[int64]map[int64]domain.ChannelMember), - dialogs: make(map[int64]map[int64]domain.ChannelDialog), - topics: make(map[int64]map[int]domain.ChannelForumTopic), - messages: make(map[int64][]domain.ChannelMessage), - reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), - paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), - top: make(map[int64]map[string]domain.TopMessageReaction), - recent: make(map[int64]map[string]domain.RecentMessageReaction), - savedTags: make(map[int64]map[string]domain.SavedReactionTag), - mentions: make(map[int64]map[int64]map[int]memoryMention), - msgViews: make(map[int64]map[int]int), - msgViewers: make(map[int64]map[int]map[int64]struct{}), - events: make(map[int64][]domain.ChannelUpdateEvent), - retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), - adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), - invites: make(map[string]domain.ChannelInvite), - importers: make(map[int64]map[int64]domain.ChannelInviteImporter), - msgSeq: make(map[int64]int), - ptsSeq: make(map[int64]int), - logSeq: make(map[int64]int64), - randomToID: make(map[channelRandomKey]int), - sendSnapshots: make(map[channelMessageReplayKey][]byte), - sendFingerprints: make(map[channelMessageReplayKey][]byte), - deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent), - boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), - readMarks: make(map[int64]channelReadWatermark), - topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), + nextID: firstMemoryChannelID, + nextHash: 900000000000, + channels: make(map[int64]domain.Channel), + members: make(map[int64]map[int64]domain.ChannelMember), + dialogs: make(map[int64]map[int64]domain.ChannelDialog), + topics: make(map[int64]map[int]domain.ChannelForumTopic), + messages: make(map[int64][]domain.ChannelMessage), + reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), + paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), + top: make(map[int64]map[string]domain.TopMessageReaction), + recent: make(map[int64]map[string]domain.RecentMessageReaction), + savedTags: make(map[int64]map[string]domain.SavedReactionTag), + mentions: make(map[int64]map[int64]map[int]memoryMention), + msgViews: make(map[int64]map[int]int), + msgViewers: make(map[int64]map[int]map[int64]struct{}), + events: make(map[int64][]domain.ChannelUpdateEvent), + retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), + adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), + invites: make(map[string]domain.ChannelInvite), + importers: make(map[int64]map[int64]domain.ChannelInviteImporter), + msgSeq: make(map[int64]int), + ptsSeq: make(map[int64]int), + logSeq: make(map[int64]int64), + randomToID: make(map[channelRandomKey]int), + sendSnapshots: make(map[channelMessageReplayKey][]byte), + sendFingerprints: make(map[channelMessageReplayKey][]byte), + deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent), + starsBalances: make(map[int64]int64), + channelStarsBalances: make(map[int64]int64), + boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), + readMarks: make(map[int64]channelReadWatermark), + topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), } } diff --git a/internal/store/memory/channel_updates.go b/internal/store/memory/channel_updates.go index 3776ac7c..8f626a96 100644 --- a/internal/store/memory/channel_updates.go +++ b/internal/store/memory/channel_updates.go @@ -49,6 +49,9 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann if msg.Deleted { continue } + if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { + continue + } if msg.ID <= member.AvailableMinID { continue } @@ -68,6 +71,16 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann } events := make([]domain.ChannelUpdateEvent, 0, limit) lastPts := req.Pts + var visibleMonoforumMessageIDs map[int]struct{} + if channel.Monoforum && !isChannelAdmin(member) { + visibleMonoforumMessageIDs = make(map[int]struct{}) + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for _, message := range s.messages[req.ChannelID] { + if message.SavedPeer == savedPeer { + visibleMonoforumMessageIDs[message.ID] = struct{}{} + } + } + } for _, event := range s.events[req.ChannelID] { if event.Pts <= req.Pts { continue @@ -77,6 +90,12 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann if !ok { continue } + if channel.Monoforum && !isChannelAdmin(member) { + visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs) + if !ok { + continue + } + } if preview && visible.Type == domain.ChannelUpdateParticipant { continue } @@ -121,6 +140,27 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann return diff, nil } +func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) { + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if event.Message.ID != 0 { + return event, event.Message.SavedPeer == savedPeer + } + if len(event.MessageIDs) == 0 { + return event, false + } + visibleIDs := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if _, ok := visibleMessageIDs[id]; ok { + visibleIDs = append(visibleIDs, id) + } + } + if len(visibleIDs) == 0 { + return event, false + } + event.MessageIDs = visibleIDs + return event, true +} + func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/memory/community.go b/internal/store/memory/community.go new file mode 100644 index 00000000..a6635416 --- /dev/null +++ b/internal/store/memory/community.go @@ -0,0 +1,1112 @@ +package memory + +import ( + "context" + "encoding/base64" + "errors" + "hash/fnv" + "sort" + "strconv" + "strings" + "sync" + + "telesrv/internal/domain" +) + +type CommunityStore struct { + mu sync.RWMutex + users *UserStore + channels *ChannelStore + bots *BotStore + dialogs *DialogStore + nextID int64 + nextHash int64 + communities map[int64]domain.Community + members map[int64]map[int64]domain.CommunityMember + links map[int64]map[domain.Peer]domain.CommunityPeerLink + requests map[int64]map[domain.Peer]domain.CommunityPeerLinkRequest + states map[int64]map[int64]domain.CommunityUserState +} + +func NewCommunityStore(users *UserStore, channels *ChannelStore, bots *BotStore, dialogs *DialogStore) *CommunityStore { + return &CommunityStore{ + users: users, channels: channels, bots: bots, dialogs: dialogs, + nextID: 3000000000, nextHash: 990000000000, + communities: map[int64]domain.Community{}, members: map[int64]map[int64]domain.CommunityMember{}, + links: map[int64]map[domain.Peer]domain.CommunityPeerLink{}, requests: map[int64]map[domain.Peer]domain.CommunityPeerLinkRequest{}, + states: map[int64]map[int64]domain.CommunityUserState{}, + } +} + +func cloneCommunity(c domain.Community) domain.Community { + c.PhotoStripped = append([]byte(nil), c.PhotoStripped...) + return c +} +func cloneCommunityView(v domain.CommunityView) domain.CommunityView { + v.Community = cloneCommunity(v.Community) + v.Links = append([]domain.CommunityPeerLink(nil), v.Links...) + v.ServiceMessages = append([]domain.SendChannelMessageResult(nil), v.ServiceMessages...) + return v +} + +func (s *CommunityStore) communityLocked(id int64) (domain.Community, error) { + c, ok := s.communities[id] + if !ok || c.Deleted { + return domain.Community{}, domain.ErrCommunityInvalid + } + return c, nil +} + +func (s *CommunityStore) derivedMemberLocked(c domain.Community, userID int64) (domain.CommunityMember, bool) { + if m, ok := s.members[c.ID][userID]; ok { + return m, true + } + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[c.ID] { + if p.Type != domain.PeerTypeChannel { + continue + } + if m, ok := s.channels.members[p.ID][userID]; ok && m.Status == domain.ChannelMemberActive { + s.channels.mu.RUnlock() + return domain.CommunityMember{CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive, Date: c.Date}, true + } + } + s.channels.mu.RUnlock() + } + if s.dialogs != nil { + s.dialogs.mu.RLock() + for p := range s.links[c.ID] { + if p.Type != domain.PeerTypeUser { + continue + } + for _, d := range s.dialogs.m[userID].Dialogs { + if d.Peer == p && d.TopMessage > 0 { + s.dialogs.mu.RUnlock() + return domain.CommunityMember{CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive, Date: c.Date}, true + } + } + } + s.dialogs.mu.RUnlock() + } + return domain.CommunityMember{}, false +} + +func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, error) { + c, e := s.communityLocked(id) + if e != nil { + return domain.CommunityView{}, e + } + m, ok := s.derivedMemberLocked(c, userID) + if !ok || !m.Active() { + return domain.CommunityView{Community: cloneCommunity(c), Self: m, Forbidden: true}, domain.ErrCommunityPrivate + } + v := domain.CommunityView{Community: cloneCommunity(c), Self: m, State: s.states[id][userID]} + v.State.CommunityID = id + v.State.UserID = userID + for _, l := range s.links[id] { + joined := false + inherentlyViewable := l.Peer.Type == domain.PeerTypeUser + if l.Peer.Type == domain.PeerTypeChannel && s.channels != nil { + s.channels.mu.RLock() + cm, ok := s.channels.members[l.Peer.ID][userID] + joined = ok && cm.Status == domain.ChannelMemberActive + if channel, ok := s.channels.channels[l.Peer.ID]; ok { + inherentlyViewable = publicPreviewableChannel(channel) + } + s.channels.mu.RUnlock() + } else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil { + s.dialogs.mu.RLock() + for _, d := range s.dialogs.m[userID].Dialogs { + if d.Peer == l.Peer && d.TopMessage > 0 { + joined = true + break + } + } + s.dialogs.mu.RUnlock() + } + if l.Visibility == domain.CommunityPeerHidden && !joined && !m.CanManageLinkedPeers() { + continue + } + // Community administration reveals hidden links but never grants access + // to a linked private channel's history. TDesktop uses this bit to decide + // between opening History directly and showing the join prompt. + l.CanViewHistory = joined || inherentlyViewable + v.Links = append(v.Links, l) + } + if s.channels != nil { + s.channels.mu.RLock() + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if ch, ok := s.channels.channels[l.Peer.ID]; ok { + v.Channels = append(v.Channels, cloneChannel(ch)) + } + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeUser { + if u, ok := s.users.byID[l.Peer.ID]; ok { + v.Users = append(v.Users, u) + } + } + } + s.users.mu.RUnlock() + } + for _, cm := range s.members[id] { + if cm.Status == domain.CommunityMemberKicked { + v.KickedCount++ + } else if cm.Role == domain.CommunityRoleCreator || cm.Role == domain.CommunityRoleAdmin { + v.AdminsCount++ + } + } + v.PendingRequests = len(s.requests[id]) + sort.Slice(v.Links, func(i, j int) bool { + if v.Links[i].Date != v.Links[j].Date { + return v.Links[i].Date < v.Links[j].Date + } + if v.Links[i].Peer.Type != v.Links[j].Peer.Type { + return v.Links[i].Peer.Type < v.Links[j].Peer.Type + } + return v.Links[i].Peer.ID < v.Links[j].Peer.ID + }) + return v, nil +} + +func (s *CommunityStore) GetCommunity(_ context.Context, userID, id int64) (domain.CommunityView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.viewLocked(userID, id) +} +func (s *CommunityStore) GetCommunities(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := []domain.CommunityView{} + seen := map[int64]struct{}{} + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + v, e := s.viewLocked(userID, id) + if errors.Is(e, domain.ErrCommunityInvalid) || errors.Is(e, domain.ErrCommunityPrivate) { + continue + } + if e != nil { + return nil, e + } + out = append(out, v) + } + return out, nil +} +func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, userID int64) ([]domain.CommunityView, error) { + s.mu.RLock() + ids := make([]int64, 0, len(s.communities)) + for id := range s.communities { + ids = append(ids, id) + } + s.mu.RUnlock() + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return s.GetCommunities(ctx, userID, ids) +} + +func (s *CommunityStore) validatePeerLocked(actor int64, p domain.Peer) error { + for _, byPeer := range s.links { + if _, ok := byPeer[p]; ok { + return domain.ErrCommunityPeerLinked + } + } + switch p.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return domain.ErrCommunityPeerInvalid + } + s.channels.mu.RLock() + defer s.channels.mu.RUnlock() + ch, ok := s.channels.channels[p.ID] + if !ok || ch.Deleted || ch.Monoforum || ch.LinkedCommunityID != 0 { + return domain.ErrCommunityPeerInvalid + } + m, ok := s.channels.members[p.ID][actor] + if !ok || m.Status != domain.ChannelMemberActive || (m.Role != domain.ChannelRoleCreator && m.Role != domain.ChannelRoleAdmin) { + return domain.ErrCommunityAdminRequired + } + case domain.PeerTypeUser: + if s.users == nil || s.bots == nil { + return domain.ErrCommunityPeerInvalid + } + s.users.mu.RLock() + u, ok := s.users.byID[p.ID] + s.users.mu.RUnlock() + if !ok || !u.Bot || u.Deleted || u.LinkedCommunityID != 0 { + return domain.ErrCommunityPeerInvalid + } + s.bots.mu.RLock() + profile, ok := s.bots.byID[p.ID] + s.bots.mu.RUnlock() + if !ok || profile.OwnerUserID != actor { + return domain.ErrCommunityAdminRequired + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func (s *CommunityStore) setPeerLinkLocked(p domain.Peer, id int64) error { + switch p.Type { + case domain.PeerTypeChannel: + s.channels.mu.Lock() + ch, ok := s.channels.channels[p.ID] + if ok { + ch.LinkedCommunityID = id + s.channels.channels[p.ID] = ch + } + s.channels.mu.Unlock() + if !ok { + return domain.ErrCommunityPeerInvalid + } + case domain.PeerTypeUser: + s.users.mu.Lock() + u, ok := s.users.byID[p.ID] + if ok { + u.LinkedCommunityID = id + s.users.byID[p.ID] = u + } + s.users.mu.Unlock() + if !ok { + return domain.ErrCommunityPeerInvalid + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func (s *CommunityStore) insertLinkLocked(id, actor int64, p domain.Peer, v domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) { + if e := s.validatePeerLocked(actor, p); e != nil { + return domain.CommunityPeerLink{}, e + } + channels, bots := 0, 0 + for peer := range s.links[id] { + if peer.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + } + if (p.Type == domain.PeerTypeChannel && channels >= domain.MaxCommunityPeers) || (p.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch + } + l := domain.CommunityPeerLink{CommunityID: id, Peer: p, Visibility: v, CanViewHistory: true, CreatedBy: actor, Date: date} + if s.links[id] == nil { + s.links[id] = map[domain.Peer]domain.CommunityPeerLink{} + } + s.links[id][p] = l + if e := s.setPeerLinkLocked(p, id); e != nil { + delete(s.links[id], p) + return domain.CommunityPeerLink{}, e + } + return l, nil +} + +func (s *CommunityStore) unlinkLocked(id int64, peer domain.Peer) { + delete(s.links[id], peer) + _ = s.setPeerLinkLocked(peer, 0) +} + +func (s *CommunityStore) CreateCommunity(_ context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.validatePeerLocked(req.CreatorUserID, req.InitialPeer); e != nil { + return domain.CommunityView{}, e + } + id := s.nextID + s.nextID++ + if s.channels != nil { + s.channels.mu.Lock() + if id < s.channels.nextID { + id = s.channels.nextID + } + s.channels.nextID = id + 1 + s.channels.mu.Unlock() + } + hash := s.nextHash + s.nextHash++ + c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date} + s.communities[id] = c + s.members[id] = map[int64]domain.CommunityMember{req.CreatorUserID: {CommunityID: id, UserID: req.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: req.Date}} + s.links[id] = map[domain.Peer]domain.CommunityPeerLink{} + s.requests[id] = map[domain.Peer]domain.CommunityPeerLinkRequest{} + s.states[id] = map[int64]domain.CommunityUserState{} + l, e := s.insertLinkLocked(id, req.CreatorUserID, req.InitialPeer, req.Visibility, req.Date) + if e != nil { + delete(s.communities, id) + return domain.CommunityView{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.InitialPeer, req.CreatorUserID, req.Date, c.ID) + if e != nil { + s.unlinkLocked(id, req.InitialPeer) + delete(s.communities, id) + return domain.CommunityView{}, e + } + view := domain.CommunityView{Community: c, Self: s.members[id][req.CreatorUserID], Links: []domain.CommunityPeerLink{l}, AdminsCount: 1} + if serviceMessage != nil { + view.ServiceMessages = append(view.ServiceMessages, *serviceMessage) + } + return view, nil +} + +func (s *CommunityStore) actorLocked(id, user int64) (domain.Community, domain.CommunityMember, error) { + c, e := s.communityLocked(id) + if e != nil { + return domain.Community{}, domain.CommunityMember{}, e + } + m, ok := s.derivedMemberLocked(c, user) + if !ok || !m.Active() { + return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate + } + return c, m, nil +} + +func (s *CommunityStore) ToggleCommunityPeerLink(_ context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(req.CommunityID, req.ActorUserID) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + if req.Deleted { + if !m.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if _, ok := s.links[c.ID][req.Peer]; !ok { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + if e := s.setPeerLinkLocked(req.Peer, 0); e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.Peer, req.ActorUserID, req.Date, 0) + if e != nil { + _ = s.setPeerLinkLocked(req.Peer, c.ID) + return domain.CommunityTogglePeerLinkResult{}, e + } + delete(s.links[c.ID], req.Peer) + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil + } + if m.CanManageLinkedPeers() { + l, e := s.insertLinkLocked(c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.Peer, req.ActorUserID, req.Date, c.ID) + if e != nil { + s.unlinkLocked(c.ID, req.Peer) + return domain.CommunityTogglePeerLinkResult{}, e + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &l, ServiceMessage: serviceMessage}, nil + } + if c.DefaultBannedRights.ManageLinkedPeers { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if e := s.validatePeerLocked(req.ActorUserID, req.Peer); e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + s.requests[c.ID][req.Peer] = domain.CommunityPeerLinkRequest{CommunityID: c.ID, Peer: req.Peer, RequestedBy: req.ActorUserID, Visibility: req.Visibility, Date: req.Date} + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil +} + +func (s *CommunityStore) appendCommunityServiceMessageLocked(peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) { + if peer.Type != domain.PeerTypeChannel || s.channels == nil { + return nil, nil + } + s.channels.mu.Lock() + defer s.channels.mu.Unlock() + channel, ok := s.channels.channels[peer.ID] + if !ok || channel.Deleted { + return nil, domain.ErrChannelInvalid + } + message, event := s.channels.appendChannelServiceMessageLocked(peer.ID, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChangeCommunity, + CommunityID: communityID, + }) + channel = s.channels.channels[peer.ID] + channel.TopMessageID = message.ID + channel.Pts = event.Pts + s.channels.channels[peer.ID] = channel + return &domain.SendChannelMessageResult{ + Channel: channel, Message: message, Event: event, + Recipients: s.channels.activeMemberIDsLocked(peer.ID, 0, 0), + }, nil +} + +func (s *CommunityStore) SetCommunityCollapsed(_ context.Context, user, id int64, collapsed bool) (domain.CommunityView, bool, error) { + s.mu.Lock() + c, _, e := s.actorLocked(id, user) + if e != nil { + s.mu.Unlock() + return domain.CommunityView{}, false, e + } + state := s.states[id][user] + changed := state.Collapsed != collapsed + state.CommunityID, state.UserID, state.Collapsed = id, user, collapsed + if !collapsed { + state.Pinned = false + state.PinnedOrder = 0 + } + s.states[id][user] = state + v, e := s.viewLocked(user, c.ID) + s.mu.Unlock() + return v, changed, e +} + +func encodeMemoryCommunityOffset(n int) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(n))) +} +func decodeMemoryCommunityOffset(raw string) (int, error) { + if raw == "" { + return 0, nil + } + b, e := base64.RawURLEncoding.DecodeString(raw) + if e != nil { + return 0, domain.ErrCommunityInvalid + } + n, e := strconv.Atoi(string(b)) + if e != nil || n < 0 { + return 0, domain.ErrCommunityInvalid + } + return n, nil +} + +func (s *CommunityStore) ListCommunityPeerLinkRequests(_ context.Context, user, id int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityPeerLinkRequestPage{}, e + } + if !m.CanManageLinkedPeers() { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired + } + start, e := decodeMemoryCommunityOffset(offset) + if e != nil { + return domain.CommunityPeerLinkRequestPage{}, e + } + items := make([]domain.CommunityPeerLinkRequest, 0, len(s.requests[id])) + for _, r := range s.requests[id] { + items = append(items, r) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Date != items[j].Date { + return items[i].Date > items[j].Date + } + if items[i].Peer.Type != items[j].Peer.Type { + return items[i].Peer.Type > items[j].Peer.Type + } + return items[i].Peer.ID > items[j].Peer.ID + }) + page := domain.CommunityPeerLinkRequestPage{TotalCount: len(items)} + if start > len(items) { + start = len(items) + } + end := start + limit + if end > len(items) { + end = len(items) + } + page.Requests = append(page.Requests, items[start:end]...) + if end < len(items) { + page.NextOffset = encodeMemoryCommunityOffset(end) + } + if s.channels != nil { + s.channels.mu.RLock() + for _, r := range page.Requests { + if r.Peer.Type == domain.PeerTypeChannel { + if ch, ok := s.channels.channels[r.Peer.ID]; ok { + page.Channels = append(page.Channels, cloneChannel(ch)) + } + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + seen := map[int64]struct{}{} + for _, r := range page.Requests { + ids := []int64{r.RequestedBy} + if r.Peer.Type == domain.PeerTypeUser { + ids = append(ids, r.Peer.ID) + } + for _, uid := range ids { + if _, ok := seen[uid]; ok { + continue + } + seen[uid] = struct{}{} + if u, ok := s.users.byID[uid]; ok { + page.Users = append(page.Users, u) + } + } + } + s.users.mu.RUnlock() + } + return page, nil +} + +func (s *CommunityStore) decideLocked(actor, id int64, p domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + c, m, e := s.actorLocked(id, actor) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + if !m.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + r, ok := s.requests[id][p] + if !ok { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityRequestMissing + } + delete(s.requests[id], p) + if reject { + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, RequestedBy: r.RequestedBy}, nil + } + l, e := s.insertLinkLocked(id, r.RequestedBy, p, r.Visibility, date) + if e != nil { + s.requests[id][p] = r + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, c.ID) + if e != nil { + s.unlinkLocked(id, p) + return domain.CommunityTogglePeerLinkResult{}, e + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, RequestedBy: r.RequestedBy, Link: &l, ServiceMessage: serviceMessage}, nil +} +func (s *CommunityStore) DecideCommunityPeerLinkRequest(_ context.Context, actor, id int64, p domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.decideLocked(actor, id, p, reject, date) +} +func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(_ context.Context, actor, id int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, m, e := s.actorLocked(id, actor) + if e != nil { + return nil, e + } + if !m.CanManageLinkedPeers() { + return nil, domain.ErrCommunityAdminRequired + } + peers := make([]domain.Peer, 0, len(s.requests[id])) + for p := range s.requests[id] { + peers = append(peers, p) + } + sort.Slice(peers, func(i, j int) bool { + if peers[i].Type != peers[j].Type { + return peers[i].Type < peers[j].Type + } + return peers[i].ID < peers[j].ID + }) + if reject { + s.requests[id] = map[domain.Peer]domain.CommunityPeerLinkRequest{} + return make([]domain.CommunityTogglePeerLinkResult, len(peers)), nil + } + channels, bots := 0, 0 + for p := range s.links[id] { + if p.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + } + for _, p := range peers { + if p.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + if channels > domain.MaxCommunityPeers || bots > domain.MaxCommunityBotPeers { + return nil, domain.ErrCommunityPeersTooMuch + } + r := s.requests[id][p] + if e := s.validatePeerLocked(r.RequestedBy, p); e != nil { + return nil, e + } + } + out := []domain.CommunityTogglePeerLinkResult{} + for _, p := range peers { + r := s.requests[id][p] + l, e := s.insertLinkLocked(id, r.RequestedBy, p, r.Visibility, date) + if e != nil { + return nil, e + } + delete(s.requests[id], p) + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, s.communities[id].ID) + if e != nil { + s.unlinkLocked(id, p) + return nil, e + } + out = append(out, domain.CommunityTogglePeerLinkResult{Community: s.communities[id], Peer: p, RequestedBy: r.RequestedBy, Link: &l, ServiceMessage: serviceMessage}) + } + return out, nil +} + +func (s *CommunityStore) GetCommunityParticipantJoinedChats(_ context.Context, user, id, participant int64) (domain.CommunityParticipantJoinedChats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityParticipantJoinedChats{}, e + } + if !m.CanBanUsers() && user != participant { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired + } + out := domain.CommunityParticipantJoinedChats{} + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[id] { + if p.Type != domain.PeerTypeChannel { + continue + } + cm, ok := s.channels.members[p.ID][participant] + if !ok || cm.Status != domain.ChannelMemberActive { + continue + } + out.JoinedChatIDs = append(out.JoinedChatIDs, p.ID) + if cm.Role == domain.ChannelRoleCreator { + out.CreatorChatIDs = append(out.CreatorChatIDs, p.ID) + } + if ch, ok := s.channels.channels[p.ID]; ok { + out.Channels = append(out.Channels, cloneChannel(ch)) + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + if participantUser, ok := s.users.byID[participant]; ok { + out.Users = append(out.Users, participantUser) + } + s.users.mu.RUnlock() + } + sort.Slice(out.JoinedChatIDs, func(i, j int) bool { return out.JoinedChatIDs[i] < out.JoinedChatIDs[j] }) + sort.Slice(out.CreatorChatIDs, func(i, j int) bool { return out.CreatorChatIDs[i] < out.CreatorChatIDs[j] }) + return out, nil +} + +func (s *CommunityStore) ToggleCommunityParticipantBanned(_ context.Context, actor, id, participant int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(id, actor) + if e != nil { + return domain.CommunityParticipantBanResult{}, e + } + if !m.CanBanUsers() || participant == c.CreatorUserID { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired + } + if unban { + old, ok := s.members[id][participant] + if ok && old.Role == domain.CommunityRoleMember && old.Status == domain.CommunityMemberKicked { + delete(s.members[id], participant) + return domain.CommunityParticipantBanResult{Changed: true}, nil + } + return domain.CommunityParticipantBanResult{}, nil + } + if participantMember, ok := s.derivedMemberLocked(c, participant); !ok || + (participantMember.Status != domain.CommunityMemberActive && participantMember.Status != domain.CommunityMemberKicked) { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid + } + old, alreadyKicked := s.members[id][participant] + alreadyKicked = alreadyKicked && old.Role == domain.CommunityRoleMember && old.Status == domain.CommunityMemberKicked + result := domain.CommunityParticipantBanResult{} + for p := range s.links[id] { + owned := false + if p.Type == domain.PeerTypeChannel && s.channels != nil { + s.channels.mu.Lock() + ch := s.channels.channels[p.ID] + owned = ch.CreatorUserID == participant + if !owned { + if cm, ok := s.channels.members[p.ID][participant]; ok && cm.Status == domain.ChannelMemberActive { + previous := cm + cm.Status = domain.ChannelMemberKicked + cm.Role = domain.ChannelRoleMember + cm.InviterUserID = actor + cm.LeftAt = date + cm.BannedRights = domain.ChannelBannedRights{ViewMessages: true} + s.channels.members[p.ID][participant] = cm + ch.ParticipantsCount-- + ch.KickedCount++ + s.channels.channels[p.ID] = ch + event := transientChannelParticipantEvent(ch.ID, actor, previous, cm, date) + var serviceMessage domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if ch.Megagroup { + serviceMessage, serviceEvent = s.channels.appendChannelServiceMessageLocked(ch.ID, actor, date, domain.ChannelMessageAction{Type: domain.ChannelActionChatDelete, UserIDs: []int64{participant}}) + ch = s.channels.channels[p.ID] + ch.TopMessageID, ch.Pts = serviceMessage.ID, serviceEvent.Pts + s.channels.channels[p.ID] = ch + } + recipients := s.channels.activeMemberIDsLocked(p.ID, 0, 0) + recipients = append(recipients, participant) + result.ChannelBans = append(result.ChannelBans, domain.EditChannelBannedResult{ + Channel: ch, Previous: previous, Participant: cm, Event: event, Recipients: recipients, + Date: date, Message: serviceMessage, ServiceEvent: serviceEvent, + }) + } + } + s.channels.mu.Unlock() + } else if p.Type == domain.PeerTypeUser && s.bots != nil { + s.bots.mu.RLock() + owned = s.bots.byID[p.ID].OwnerUserID == participant + s.bots.mu.RUnlock() + } + if owned { + if e := s.setPeerLinkLocked(p, 0); e != nil { + return domain.CommunityParticipantBanResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, 0) + if e != nil { + _ = s.setPeerLinkLocked(p, c.ID) + return domain.CommunityParticipantBanResult{}, e + } + delete(s.links[id], p) + result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage}) + } + } + if !alreadyKicked { + s.members[id][participant] = domain.CommunityMember{CommunityID: id, UserID: participant, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberKicked, Date: date} + } + result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0 + return result, nil +} + +func (s *CommunityStore) allParticipantsLocked(id int64) []domain.CommunityMember { + byID := map[int64]domain.CommunityMember{} + for uid, m := range s.members[id] { + byID[uid] = m + } + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[id] { + if p.Type != domain.PeerTypeChannel { + continue + } + for uid, cm := range s.channels.members[p.ID] { + if cm.Status == domain.ChannelMemberActive { + if _, ok := byID[uid]; !ok { + byID[uid] = domain.CommunityMember{CommunityID: id, UserID: uid, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive} + } + } + } + } + s.channels.mu.RUnlock() + } + out := make([]domain.CommunityMember, 0, len(byID)) + for _, m := range byID { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { + rank := func(r domain.CommunityMemberRole) int { + if r == domain.CommunityRoleCreator { + return 0 + } + if r == domain.CommunityRoleAdmin { + return 1 + } + return 2 + } + if rank(out[i].Role) != rank(out[j].Role) { + return rank(out[i].Role) < rank(out[j].Role) + } + return out[i].UserID < out[j].UserID + }) + return out +} +func (s *CommunityStore) ListCommunityParticipants(_ context.Context, user, id int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityParticipantList{}, e + } + restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned + if restricted && !m.CanManageLinkedPeers() { + return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired + } + all := s.allParticipantsLocked(id) + query := strings.ToLower(strings.TrimSpace(filter.Query)) + usersByID := make(map[int64]domain.User, len(all)) + if s.users != nil { + s.users.mu.RLock() + for _, participant := range all { + if user, ok := s.users.byID[participant.UserID]; ok { + usersByID[participant.UserID] = user + } + } + s.users.mu.RUnlock() + } + items := all[:0] + for _, p := range all { + ok := p.Status == domain.CommunityMemberActive + if filter.Kind == domain.ChannelParticipantsAdmins { + ok = ok && (p.Role == domain.CommunityRoleCreator || p.Role == domain.CommunityRoleAdmin) + } else if filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned { + ok = p.Status == domain.CommunityMemberKicked + } + if ok && query != "" { + user := usersByID[p.UserID] + haystack := strings.ToLower(strings.Join([]string{ + strconv.FormatInt(p.UserID, 10), user.FirstName, user.LastName, user.Username, user.Phone, + }, " ")) + ok = strings.Contains(haystack, query) + } + if ok { + items = append(items, p) + } + } + out := domain.CommunityParticipantList{Community: c, Count: len(items)} + if offset > len(items) { + offset = len(items) + } + end := offset + limit + if end > len(items) { + end = len(items) + } + out.Participants = append([]domain.CommunityMember(nil), items[offset:end]...) + h := fnv.New64a() + for _, p := range out.Participants { + _, _ = h.Write([]byte(strconv.FormatInt(p.UserID, 10) + string(p.Role) + string(p.Status))) + } + out.Hash = int64(h.Sum64() & 0x7fffffffffffffff) + for _, p := range out.Participants { + if user, ok := usersByID[p.UserID]; ok { + out.Users = append(out.Users, user) + } + } + return out, nil +} + +func (s *CommunityStore) editViewLocked(user, id int64, can func(domain.CommunityMember) bool, edit func(*domain.Community) bool) (domain.CommunityView, bool, error) { + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityView{}, false, e + } + if !can(m) { + return domain.CommunityView{}, false, domain.ErrCommunityAdminRequired + } + changed := edit(&c) + s.communities[id] = c + v, e := s.viewLocked(user, id) + return v, changed, e +} +func (s *CommunityStore) EditCommunityTitle(_ context.Context, user, id int64, title string) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.Title == title { + return false + } + c.Title = title + return true + }) +} +func (s *CommunityStore) EditCommunityAbout(_ context.Context, user, id int64, about string) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.About == about { + return false + } + c.About = about + return true + }) +} +func (s *CommunityStore) EditCommunityDefaultBannedRights(_ context.Context, user, id int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.DefaultBannedRights == rights { + return false + } + c.DefaultBannedRights = rights + return true + }) +} +func (s *CommunityStore) SetCommunityPhoto(_ context.Context, user, id int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + pid, dc := int64(0), 0 + var stripped []byte + if photo != nil { + pid, dc = photo.ID, photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if c.PhotoID == pid && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) { + return false + } + c.PhotoID, c.PhotoDCID, c.PhotoStripped = pid, dc, append([]byte(nil), stripped...) + return true + }) +} +func (s *CommunityStore) EditCommunityAdmin(_ context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(req.CommunityID, req.ActorUserID) + if e != nil { + return domain.CommunityView{}, false, e + } + zero := req.Rights == (domain.ChannelAdminRights{}) + if req.UserID == req.ActorUserID && m.Role == domain.CommunityRoleAdmin && zero { + delete(s.members[c.ID], req.UserID) + v, e := s.viewLocked(req.ActorUserID, c.ID) + if errors.Is(e, domain.ErrCommunityPrivate) { + return domain.CommunityView{Community: c, Self: m, Forbidden: true}, true, nil + } + return v, true, e + } + if !m.CanAddAdmins() { + return domain.CommunityView{}, false, domain.ErrCommunityAdminRequired + } + if req.UserID == c.CreatorUserID { + return domain.CommunityView{}, false, domain.ErrCommunityCreatorRequired + } + old, ok := s.members[c.ID][req.UserID] + if zero { + if ok && old.Role == domain.CommunityRoleAdmin { + delete(s.members[c.ID], req.UserID) + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, true, e + } + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, false, e + } + s.members[c.ID][req.UserID] = domain.CommunityMember{CommunityID: c.ID, UserID: req.UserID, Role: domain.CommunityRoleAdmin, Status: domain.CommunityMemberActive, AdminRights: req.Rights, Rank: req.Rank, Date: req.Date} + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, true, e +} + +func (s *CommunityStore) DeleteCommunity(_ context.Context, user, id int64, date int) (domain.CommunityView, []domain.Peer, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityView{}, nil, e + } + if m.Role != domain.CommunityRoleCreator { + return domain.CommunityView{}, nil, domain.ErrCommunityCreatorRequired + } + peers := make([]domain.Peer, 0, len(s.links[id])) + serviceMessages := make([]domain.SendChannelMessageResult, 0, len(s.links[id])) + for p := range s.links[id] { + peers = append(peers, p) + if e := s.setPeerLinkLocked(p, 0); e != nil { + return domain.CommunityView{}, nil, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, user, date, 0) + if e != nil { + _ = s.setPeerLinkLocked(p, c.ID) + return domain.CommunityView{}, nil, e + } + if serviceMessage != nil { + serviceMessages = append(serviceMessages, *serviceMessage) + } + } + c.Deleted = true + c.Title = "" + c.About = "" + s.communities[id] = c + delete(s.links, id) + delete(s.requests, id) + delete(s.states, id) + return domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers, nil +} +func (s *CommunityStore) SetCommunityPinned(_ context.Context, user, id int64, pinned bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, _, e := s.actorLocked(id, user); e != nil { + return false, e + } + st, ok := s.states[id][user] + if !ok || !st.Collapsed { + return false, domain.ErrCommunityInvalid + } + if st.Pinned == pinned { + return false, nil + } + st.Pinned = pinned + if pinned { + max := 1000000000 + for _, byUser := range s.states { + if x, ok := byUser[user]; ok && x.Pinned && x.PinnedOrder > max { + max = x.PinnedOrder + } + } + st.PinnedOrder = max + 1 + } else { + st.PinnedOrder = 0 + } + s.states[id][user] = st + return true, nil +} +func (s *CommunityStore) ReorderCommunityPinned(_ context.Context, user int64, order []domain.Peer, force bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + seen := map[int64]struct{}{} + for _, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + if _, ok := seen[peer.ID]; ok { + return false, domain.ErrCommunityInvalid + } + seen[peer.ID] = struct{}{} + st, ok := s.states[peer.ID][user] + if !ok || !st.Collapsed { + return false, domain.ErrCommunityInvalid + } + } + changed := false + for id, byUser := range s.states { + if st, ok := byUser[user]; ok && st.Pinned { + if force { + st.Pinned = false + st.PinnedOrder = 0 + s.states[id][user] = st + changed = true + } + } + } + for i, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + st := s.states[peer.ID][user] + pinnedOrder := len(order) - i + if st.Pinned && st.PinnedOrder == pinnedOrder { + continue + } + st.Pinned = true + st.PinnedOrder = pinnedOrder + s.states[peer.ID][user] = st + changed = true + } + return changed, nil +} +func (s *CommunityStore) CommunitySearchScope(ctx context.Context, user, id int64) (domain.CommunitySearchScope, error) { + v, e := s.GetCommunity(ctx, user, id) + if e != nil { + return domain.CommunitySearchScope{}, e + } + out := domain.CommunitySearchScope{CommunityID: id} + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if l.CanViewHistory { + out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID) + } + } else { + out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID) + } + } + return out, nil +} diff --git a/internal/store/memory/community_test.go b/internal/store/memory/community_test.go new file mode 100644 index 00000000..1efbf5d5 --- /dev/null +++ b/internal/store/memory/community_test.go @@ -0,0 +1,285 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func mustCommunityTestUser(t *testing.T, store *UserStore, firstName, phone string) domain.User { + t.Helper() + user, err := store.Create(context.Background(), domain.User{AccessHash: int64(len(phone) + len(firstName)), Phone: phone, FirstName: firstName}) + if err != nil { + t.Fatalf("create user %q: %v", firstName, err) + } + return user +} + +func mustCommunityTestChannel(t *testing.T, store *ChannelStore, creator domain.User, title string, members ...domain.User) domain.Channel { + t.Helper() + memberIDs := make([]int64, 0, len(members)) + for _, member := range members { + memberIDs = append(memberIDs, member.ID) + } + created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: title, + Megagroup: true, + MemberUserIDs: memberIDs, + Date: 1_800_000_000, + }) + if err != nil { + t.Fatalf("create channel %q: %v", title, err) + } + return created.Channel +} + +func TestCommunityLifecycleRequestsSearchAndModeration(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Community Owner", "15551000001") + member := mustCommunityTestUser(t, users, "Alice Searchable", "15551000002") + channels := NewChannelStore() + initial := mustCommunityTestChannel(t, channels, owner, "Initial", member) + store := NewCommunityStore(users, channels, nil, nil) + + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Engineering", + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID}, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_000_001, + }) + if err != nil { + t.Fatalf("create community: %v", err) + } + if len(created.Links) != 1 || created.Links[0].Visibility != domain.CommunityPeerHidden { + t.Fatalf("initial links = %+v, want one hidden link", created.Links) + } + if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Message.Action == nil || + created.ServiceMessages[0].Message.Action.Type != domain.ChannelActionChangeCommunity || + created.ServiceMessages[0].Message.Action.CommunityID != created.Community.ID || created.ServiceMessages[0].Event.Pts == 0 { + t.Fatalf("create service messages = %+v, want durable change-community action", created.ServiceMessages) + } + initialView, err := channels.GetChannel(ctx, owner.ID, initial.ID) + if err != nil || initialView.Channel.LinkedCommunityID != created.Community.ID { + t.Fatalf("initial linked community = %d, err=%v, want %d", initialView.Channel.LinkedCommunityID, err, created.Community.ID) + } + + _, err = store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Duplicate", + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_002, + }) + if !errors.Is(err, domain.ErrCommunityPeerLinked) { + t.Fatalf("reuse linked peer error = %v, want ErrCommunityPeerLinked", err) + } + + owned := mustCommunityTestChannel(t, channels, member, "Member Owned") + requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: member.ID, + CommunityID: created.Community.ID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_003, + }) + if err != nil || !requested.RequestCreated { + t.Fatalf("link request = %+v, err=%v, want pending request", requested, err) + } + page, err := store.ListCommunityPeerLinkRequests(ctx, owner.ID, created.Community.ID, "", 20) + if err != nil || page.TotalCount != 1 || len(page.Requests) != 1 || page.Requests[0].RequestedBy != member.ID { + t.Fatalf("request page = %+v, err=%v", page, err) + } + approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, created.Community.ID, requested.Peer, false, 1_800_000_004) + if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil { + t.Fatalf("approved request = %+v, err=%v", approved, err) + } + ownerView, err := store.GetCommunity(ctx, owner.ID, created.Community.ID) + if err != nil { + t.Fatalf("owner community view after approval: %v", err) + } + for _, link := range ownerView.Links { + if link.Peer == approved.Peer && link.CanViewHistory { + t.Fatalf("community admin without private-channel membership advertised can_view_history") + } + } + privateVisible := mustCommunityTestChannel(t, channels, owner, "Private Visible") + linked, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: owner.ID, + CommunityID: created.Community.ID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: privateVisible.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_004, + }) + if err != nil || linked.Link == nil { + t.Fatalf("link private visible channel = %+v, err=%v", linked, err) + } + memberView, err := store.GetCommunity(ctx, member.ID, created.Community.ID) + if err != nil { + t.Fatalf("member community view: %v", err) + } + foundPrivate := false + for _, link := range memberView.Links { + if link.Peer.ID == privateVisible.ID { + foundPrivate = true + if link.CanViewHistory { + t.Fatalf("private visible channel advertised can_view_history to non-member") + } + } + } + if !foundPrivate { + t.Fatalf("visible private channel missing from member community view") + } + + _, err = store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: owner.ID, + CommunityID: created.Community.ID, + Peer: approved.Peer, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_000_005, + }) + if !errors.Is(err, domain.ErrCommunityPeerLinked) { + t.Fatalf("change visibility in place error = %v, want unlink/relink requirement", err) + } + + participants, err := store.ListCommunityParticipants(ctx, owner.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsSearch, + Query: "searchABLE", + }, 0, 20) + if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID { + t.Fatalf("participant name search = %+v, err=%v, want member", participants, err) + } + admins, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100) + if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID { + t.Fatalf("member-visible Community admins = %+v, err=%v, want creator", admins, err) + } + if _, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsKicked, + }, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) { + t.Fatalf("member Community kicked list error = %v, want admin required", err) + } + outsider := mustCommunityTestUser(t, users, "Community Outsider", "15551000003") + if _, err := store.ListCommunityParticipants(ctx, outsider.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("outsider Community admins error = %v, want private", err) + } + + ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_006) + if err != nil { + t.Fatalf("ban community participant: %v", err) + } + if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 || ban.RemovedLinks[0].Peer.ID != owned.ID { + t.Fatalf("ban result = %+v, want one channel ban and owned-link removal", ban) + } + if action := ban.RemovedLinks[0].ServiceMessage.Message.Action; action == nil || action.Type != domain.ChannelActionChangeCommunity || action.CommunityID != 0 { + t.Fatalf("unlink service action = %+v, want change-community(0)", action) + } + if got := ban.RemovedLinks[0].ServiceMessage.Channel.LinkedCommunityID; got != 0 { + t.Fatalf("unlink service channel linked community = %d, want 0", got) + } + kicked, err := channels.GetParticipant(ctx, owner.ID, initial.ID, member.ID) + if err != nil || kicked.Status != domain.ChannelMemberKicked || !kicked.BannedRights.ViewMessages { + t.Fatalf("linked channel participant = %+v, err=%v, want kicked", kicked, err) + } + ownedView, err := channels.GetChannel(ctx, member.ID, owned.ID) + if err != nil || ownedView.Channel.LinkedCommunityID != 0 { + t.Fatalf("owned channel linked community = %d, err=%v, want 0", ownedView.Channel.LinkedCommunityID, err) + } + if _, err := store.GetCommunity(ctx, member.ID, created.Community.ID); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("banned member get community error = %v, want private", err) + } + repeated, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_007) + if err != nil || repeated.Changed || len(repeated.ChannelBans) != 0 || len(repeated.RemovedLinks) != 0 { + t.Fatalf("repeated ban = %+v err=%v, want idempotent no-op", repeated, err) + } +} + +func TestCommunityCollapsedPinAndMixedOrder(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Owner", "15551000011") + channels := NewChannelStore() + store := NewCommunityStore(users, channels, nil, nil) + + makeCommunity := func(title string) domain.CommunityView { + channel := mustCommunityTestChannel(t, channels, owner, title+" Channel") + view, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: title, + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_100, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + if _, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil || !changed { + t.Fatalf("collapse %s: changed=%v err=%v", title, changed, err) + } + if changed, err := store.SetCommunityPinned(ctx, owner.ID, view.Community.ID, true); err != nil || !changed { + t.Fatalf("pin %s: changed=%v err=%v", title, changed, err) + } + return view + } + one := makeCommunity("One") + two := makeCommunity("Two") + changed, err := store.ReorderCommunityPinned(ctx, owner.ID, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: 77}, + {Type: domain.PeerTypeCommunity, ID: one.Community.ID}, + {Type: domain.PeerTypeUser, ID: 88}, + {Type: domain.PeerTypeCommunity, ID: two.Community.ID}, + }, true) + if err != nil || !changed { + t.Fatalf("mixed pinned reorder: changed=%v err=%v", changed, err) + } + oneView, _ := store.GetCommunity(ctx, owner.ID, one.Community.ID) + twoView, _ := store.GetCommunity(ctx, owner.ID, two.Community.ID) + if !oneView.State.Pinned || !twoView.State.Pinned || oneView.State.PinnedOrder <= twoView.State.PinnedOrder { + t.Fatalf("pinned orders one=%+v two=%+v, want global mixed order preserved", oneView.State, twoView.State) + } + uncollapsed, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, one.Community.ID, false) + if err != nil || !changed || uncollapsed.State.Pinned { + t.Fatalf("uncollapse state = %+v changed=%v err=%v, want pin cleared", uncollapsed.State, changed, err) + } +} + +func TestCommunityCanUseOwnedBotAsInitialPeer(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Bot Owner", "15551000021") + bots := NewBotStore(users) + bot, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: 91, FirstName: "Community Bot", Username: "community_bot", + }, domain.BotProfile{OwnerUserID: owner.ID}) + if err != nil { + t.Fatalf("create bot: %v", err) + } + store := NewCommunityStore(users, NewChannelStore(), bots, nil) + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Bot Community", + InitialPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_200, + }) + if err != nil { + t.Fatalf("create bot community: %v", err) + } + if len(created.Links) != 1 || created.Links[0].Peer.ID != bot.ID || !created.Links[0].CanViewHistory { + t.Fatalf("bot community links = %+v", created.Links) + } + if len(created.ServiceMessages) != 0 { + t.Fatalf("bot link service messages = %+v, want none", created.ServiceMessages) + } + updatedBot, ok, err := users.ByID(ctx, bot.ID) + if err != nil || !ok || updatedBot.LinkedCommunityID != created.Community.ID { + t.Fatalf("bot linked community = %d ok=%v err=%v", updatedBot.LinkedCommunityID, ok, err) + } +} diff --git a/internal/store/memory/dialogs.go b/internal/store/memory/dialogs.go index 4f68b1fd..66054389 100644 --- a/internal/store/memory/dialogs.go +++ b/internal/store/memory/dialogs.go @@ -868,6 +868,14 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { draft.WebPage = &webpage } draft.RichMessage = cloneRichMessage(draft.RichMessage) + if draft.SuggestedPost != nil { + suggested := *draft.SuggestedPost + if suggested.Price != nil { + price := *suggested.Price + suggested.Price = &price + } + draft.SuggestedPost = &suggested + } return draft } diff --git a/internal/store/memory/ephemeral.go b/internal/store/memory/ephemeral.go new file mode 100644 index 00000000..cb36a24f --- /dev/null +++ b/internal/store/memory/ephemeral.go @@ -0,0 +1,380 @@ +package memory + +import ( + "container/heap" + "context" + "sync" + "sync/atomic" + "time" + + "telesrv/internal/domain" +) + +const ephemeralShardCount = 64 + +type ephemeralMessageKey struct { + peerType domain.PeerType + peerID int64 + id int +} + +type ephemeralRandomKey struct { + peerType domain.PeerType + peerID int64 + senderID int64 + receiverID int64 + randomID int64 +} + +type ephemeralEntry struct { + message domain.EphemeralMessage + generation uint64 +} + +type ephemeralExpiry struct { + key ephemeralMessageKey + expiresAt int64 + generation uint64 +} + +type ephemeralExpiryHeap []ephemeralExpiry + +func (h ephemeralExpiryHeap) Len() int { return len(h) } +func (h ephemeralExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt } +func (h ephemeralExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *ephemeralExpiryHeap) Push(value any) { + *h = append(*h, value.(ephemeralExpiry)) +} + +func (h *ephemeralExpiryHeap) Pop() any { + old := *h + n := len(old) + value := old[n-1] + old[n-1] = ephemeralExpiry{} + *h = old[:n-1] + return value +} + +type ephemeralShard struct { + mu sync.RWMutex + messages map[ephemeralMessageKey]ephemeralEntry + random map[ephemeralRandomKey]ephemeralMessageKey + expiry ephemeralExpiryHeap + nextGeneration uint64 +} + +type ephemeralCallbackActionShard struct { + mu sync.RWMutex + actions map[int64]ephemeralCallbackActionEntry + expiry ephemeralCallbackExpiryHeap + nextGeneration uint64 +} + +type ephemeralCallbackActionEntry struct { + action domain.EphemeralCallbackAction + generation uint64 +} + +type ephemeralCallbackExpiry struct { + queryID int64 + expiresAt int64 + generation uint64 +} + +type ephemeralCallbackExpiryHeap []ephemeralCallbackExpiry + +func (h ephemeralCallbackExpiryHeap) Len() int { return len(h) } +func (h ephemeralCallbackExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt } +func (h ephemeralCallbackExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *ephemeralCallbackExpiryHeap) Push(value any) { + *h = append(*h, value.(ephemeralCallbackExpiry)) +} + +func (h *ephemeralCallbackExpiryHeap) Pop() any { + old := *h + n := len(old) + value := old[n-1] + old[n-1] = ephemeralCallbackExpiry{} + *h = old[:n-1] + return value +} + +// EphemeralMessageStore shards by peer. A create touches one shard, so the ID +// and random-ID indexes can be updated atomically without a process-wide lock. +type EphemeralMessageStore struct { + shards [ephemeralShardCount]ephemeralShard + callbackActions [ephemeralShardCount]ephemeralCallbackActionShard + messageCursor atomic.Uint32 + callbackCursor atomic.Uint32 +} + +func NewEphemeralMessageStore() *EphemeralMessageStore { + s := &EphemeralMessageStore{} + for i := range s.shards { + s.shards[i].messages = make(map[ephemeralMessageKey]ephemeralEntry) + s.shards[i].random = make(map[ephemeralRandomKey]ephemeralMessageKey) + s.callbackActions[i].actions = make(map[int64]ephemeralCallbackActionEntry) + } + return s +} + +func (s *EphemeralMessageStore) PutEphemeralCallbackAction(_ context.Context, action domain.EphemeralCallbackAction) (bool, error) { + if action.QueryID == 0 || action.BotUserID <= 0 || action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel || + action.Peer.ID <= 0 || action.MessageID <= 0 || action.Device.UserID != action.UserID || + action.Device.BusinessAuthKeyID == ([8]byte{}) || action.CreatedAt.IsZero() || !action.ExpiresAt.After(action.CreatedAt) || + action.ExpiresAt.Sub(action.CreatedAt) > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralInvalid + } + shard := &s.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)] + shard.mu.Lock() + defer shard.mu.Unlock() + if existing, ok := shard.actions[action.QueryID]; ok && action.CreatedAt.Before(existing.action.ExpiresAt) { + return false, nil + } + shard.nextGeneration++ + entry := ephemeralCallbackActionEntry{action: action, generation: shard.nextGeneration} + shard.actions[action.QueryID] = entry + heap.Push(&shard.expiry, ephemeralCallbackExpiry{ + queryID: action.QueryID, expiresAt: action.ExpiresAt.UnixNano(), generation: entry.generation, + }) + return true, nil +} + +func (s *EphemeralMessageStore) GetEphemeralCallbackAction(_ context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) { + if botUserID <= 0 || queryID == 0 { + return domain.EphemeralCallbackAction{}, false, nil + } + shard := &s.callbackActions[uint64(queryID)&(ephemeralShardCount-1)] + shard.mu.RLock() + entry, ok := shard.actions[queryID] + if ok && entry.action.BotUserID == botUserID && now.Before(entry.action.ExpiresAt) { + shard.mu.RUnlock() + return entry.action, true, nil + } + shard.mu.RUnlock() + if !ok || entry.action.BotUserID != botUserID { + return domain.EphemeralCallbackAction{}, false, nil + } + shard.mu.Lock() + if current, exists := shard.actions[queryID]; exists && !now.Before(current.action.ExpiresAt) { + delete(shard.actions, queryID) + } + shard.mu.Unlock() + return domain.EphemeralCallbackAction{}, false, nil +} + +func (s *EphemeralMessageStore) CreateEphemeralMessage(_ context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) { + now := message.CreatedAt + if err := message.ValidateForCreate(now); err != nil { + return domain.EphemeralMessage{}, false, err + } + shard := s.shard(message.Peer) + messageKey := ephemeralKey(message.Peer, message.ID) + randomKey := ephemeralRandom(message) + shard.mu.Lock() + defer shard.mu.Unlock() + + if existingKey, ok := shard.random[randomKey]; ok { + if existing, found := shard.messages[existingKey]; found && !existing.message.Expired(now) { + if existing.message.PayloadHash != message.PayloadHash { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict + } + return cloneEphemeralMessage(existing.message), false, nil + } + delete(shard.random, randomKey) + delete(shard.messages, existingKey) + } + if existing, ok := shard.messages[messageKey]; ok { + if !existing.message.Expired(now) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision + } + delete(shard.random, ephemeralRandom(existing.message)) + delete(shard.messages, messageKey) + } + stored := cloneEphemeralMessage(message) + stored.BotAPIReply = nil + shard.nextGeneration++ + entry := ephemeralEntry{message: stored, generation: shard.nextGeneration} + shard.messages[messageKey] = entry + shard.random[randomKey] = messageKey + heap.Push(&shard.expiry, ephemeralExpiry{ + key: messageKey, + expiresAt: stored.ExpiresAt.UnixNano(), + generation: entry.generation, + }) + return cloneEphemeralMessage(stored), true, nil +} + +func (s *EphemeralMessageStore) GetEphemeralMessage(_ context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.RLock() + entry, ok := shard.messages[key] + if ok && !entry.message.Expired(now) { + message := cloneEphemeralMessage(entry.message) + shard.mu.RUnlock() + return message, true, nil + } + shard.mu.RUnlock() + if !ok { + return domain.EphemeralMessage{}, false, nil + } + shard.mu.Lock() + if entry, ok = shard.messages[key]; ok && entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + } + shard.mu.Unlock() + return domain.EphemeralMessage{}, false, nil +} + +func (s *EphemeralMessageStore) EditEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.Lock() + defer shard.mu.Unlock() + entry, ok := shard.messages[key] + if !ok { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + return domain.EphemeralMessage{}, domain.ErrEphemeralExpired + } + if entry.message.Deleted { + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + } + if expectedVersion == 0 || entry.message.Version != expectedVersion { + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + } + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + entry.message.Content = cloneEphemeralContent(content) + entry.message.EditDate = editDate + entry.message.Version++ + shard.messages[key] = entry + return cloneEphemeralMessage(entry.message), nil +} + +func (s *EphemeralMessageStore) DeleteEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.Lock() + defer shard.mu.Unlock() + entry, ok := shard.messages[key] + if !ok { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + } + if entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + return domain.EphemeralMessage{}, false, domain.ErrEphemeralExpired + } + if entry.message.Deleted { + return cloneEphemeralMessage(entry.message), false, nil + } + if expectedVersion == 0 || entry.message.Version != expectedVersion { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + } + entry.message.Deleted = true + entry.message.Version++ + // Keep a small tombstone until the original TTL. It prevents a delayed + // random-id retry from resurrecting a message after delete. + entry.message.Content = domain.EphemeralContent{} + shard.messages[key] = entry + return cloneEphemeralMessage(entry.message), true, nil +} + +func (s *EphemeralMessageStore) PruneExpiredEphemeralMessages(_ context.Context, now time.Time, limit int) (int, error) { + if limit <= 0 { + return 0, nil + } + deleted := 0 + nowUnixNano := now.UnixNano() + start := int(s.messageCursor.Add(1)-1) & (ephemeralShardCount - 1) + for offset := range ephemeralShardCount { + shard := &s.shards[(start+offset)&(ephemeralShardCount-1)] + shard.mu.Lock() + for deleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano { + expiry := heap.Pop(&shard.expiry).(ephemeralExpiry) + entry, ok := shard.messages[expiry.key] + if !ok || entry.generation != expiry.generation { + continue + } + delete(shard.messages, expiry.key) + delete(shard.random, ephemeralRandom(entry.message)) + deleted++ + } + shard.mu.Unlock() + if deleted >= limit { + break + } + } + // Callback authorizations have an independent 15-second TTL. Give their + // heap an independent bounded budget so a hot message shard cannot starve + // callback cleanup and cause an in-memory deployment to grow forever. + callbackDeleted := 0 + callbackStart := int(s.callbackCursor.Add(1)-1) & (ephemeralShardCount - 1) + for offset := range ephemeralShardCount { + shard := &s.callbackActions[(callbackStart+offset)&(ephemeralShardCount-1)] + shard.mu.Lock() + for callbackDeleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano { + expiry := heap.Pop(&shard.expiry).(ephemeralCallbackExpiry) + entry, ok := shard.actions[expiry.queryID] + if !ok || entry.generation != expiry.generation { + continue + } + delete(shard.actions, expiry.queryID) + callbackDeleted++ + } + shard.mu.Unlock() + if callbackDeleted >= limit { + break + } + } + return deleted, nil +} + +func (s *EphemeralMessageStore) shard(peer domain.Peer) *ephemeralShard { + // Peer IDs are already uniformly allocated monotonically; multiplicative + // mixing avoids adjacent hot groups concentrating in neighboring low bits. + index := (uint64(peer.ID) * 11400714819323198485) >> (64 - 6) + return &s.shards[index] +} + +func ephemeralKey(peer domain.Peer, id int) ephemeralMessageKey { + return ephemeralMessageKey{peerType: peer.Type, peerID: peer.ID, id: id} +} + +func ephemeralRandom(message domain.EphemeralMessage) ephemeralRandomKey { + return ephemeralRandomKey{ + peerType: message.Peer.Type, + peerID: message.Peer.ID, + senderID: message.SenderUserID, + receiverID: message.ReceiverUserID, + randomID: message.RandomID, + } +} + +func cloneEphemeralMessage(message domain.EphemeralMessage) domain.EphemeralMessage { + message.Content = cloneEphemeralContent(message.Content) + if message.BotAPIReply != nil { + reply := *message.BotAPIReply + reply.Content = cloneEphemeralContent(reply.Content) + reply.BotAPIReply = nil + message.BotAPIReply = &reply + } + return message +} + +func cloneEphemeralContent(content domain.EphemeralContent) domain.EphemeralContent { + content.Entities = append([]domain.MessageEntity(nil), content.Entities...) + content.Media = cloneRequestedPeerMedia(content.Media) + content.ReplyMarkup = cloneReplyMarkup(content.ReplyMarkup) + content.RichMessage = cloneRichMessage(content.RichMessage) + return content +} diff --git a/internal/store/memory/ephemeral_report.go b/internal/store/memory/ephemeral_report.go new file mode 100644 index 00000000..ed3ca8aa --- /dev/null +++ b/internal/store/memory/ephemeral_report.go @@ -0,0 +1,53 @@ +package memory + +import ( + "context" + "sync" + + "telesrv/internal/domain" +) + +type ephemeralReportKey struct { + reporterUserID int64 + channelID int64 + messageID int + option string + commentHash [32]byte +} + +// EphemeralReportStore is the deterministic in-memory test implementation. +type EphemeralReportStore struct { + mu sync.Mutex + reports map[ephemeralReportKey]domain.EphemeralAbuseReport +} + +func NewEphemeralReportStore() *EphemeralReportStore { + return &EphemeralReportStore{reports: make(map[ephemeralReportKey]domain.EphemeralAbuseReport)} +} + +func (s *EphemeralReportStore) CreateEphemeralReport(_ context.Context, report domain.EphemeralAbuseReport) (bool, error) { + if err := report.Validate(); err != nil { + return false, err + } + key := ephemeralReportKey{ + reporterUserID: report.ReporterUserID, channelID: report.Evidence.Peer.ID, + messageID: report.Evidence.MessageID, option: report.Option, commentHash: report.CommentHash, + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.reports[key]; exists { + return false, nil + } + s.reports[key] = report + return true, nil +} + +func (s *EphemeralReportStore) Reports() []domain.EphemeralAbuseReport { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.EphemeralAbuseReport, 0, len(s.reports)) + for _, report := range s.reports { + out = append(out, report) + } + return out +} diff --git a/internal/store/memory/ephemeral_test.go b/internal/store/memory/ephemeral_test.go new file mode 100644 index 00000000..4648d978 --- /dev/null +++ b/internal/store/memory/ephemeral_test.go @@ -0,0 +1,184 @@ +package memory + +import ( + "context" + "crypto/sha256" + "errors" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestEphemeralMessageStoreCreateReplayEditDeleteAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + message := testEphemeralMessage(now) + created, fresh, err := store.CreateEphemeralMessage(ctx, message) + if err != nil || !fresh || created.ID != message.ID { + t.Fatalf("create = %+v fresh=%v err=%v", created, fresh, err) + } + + replayed, fresh, err := store.CreateEphemeralMessage(ctx, message) + if err != nil || fresh || replayed.Version != 1 { + t.Fatalf("replay = %+v fresh=%v err=%v", replayed, fresh, err) + } + conflict := message + conflict.ID++ + conflict.PayloadHash = sha256.Sum256([]byte("different")) + if _, _, err := store.CreateEphemeralMessage(ctx, conflict); !errors.Is(err, domain.ErrEphemeralRandomIDConflict) { + t.Fatalf("random-id conflict err=%v", err) + } + + edited, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, int(now.Unix())+1, now) + if err != nil || edited.Version != 2 || edited.Content.Message != "edited" { + t.Fatalf("edit = %+v err=%v", edited, err) + } + if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "stale"}, int(now.Unix())+2, now); !errors.Is(err, domain.ErrEphemeralVersionConflict) { + t.Fatalf("stale edit err=%v", err) + } + + deleted, changed, err := store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now) + if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 || deleted.Content.Message != "" { + t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err) + } + deleted, changed, err = store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 3, now) + if err != nil || changed || !deleted.Deleted { + t.Fatalf("repeat delete = %+v changed=%v err=%v", deleted, changed, err) + } + if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 3, domain.EphemeralContent{Message: "resurrect"}, int(now.Unix())+3, now); !errors.Is(err, domain.ErrEphemeralDeleted) { + t.Fatalf("edit deleted err=%v", err) + } + + if _, found, err := store.GetEphemeralMessage(ctx, message.Peer, message.ID, message.ExpiresAt); err != nil || found { + t.Fatalf("expired found=%v err=%v", found, err) + } +} + +func TestEphemeralMessageStoreIDCollisionAndBoundedPrune(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_100, 0) + first := testEphemeralMessage(now) + if _, _, err := store.CreateEphemeralMessage(ctx, first); err != nil { + t.Fatal(err) + } + second := first + second.RandomID++ + second.PayloadHash = sha256.Sum256([]byte("second")) + if _, _, err := store.CreateEphemeralMessage(ctx, second); !errors.Is(err, domain.ErrEphemeralIDCollision) { + t.Fatalf("id collision err=%v", err) + } + if got, err := store.PruneExpiredEphemeralMessages(ctx, first.ExpiresAt, 1); err != nil || got != 1 { + t.Fatalf("prune=%d err=%v", got, err) + } +} + +func TestEphemeralCallbackActionExactBotAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + action := domain.EphemeralCallbackAction{ + QueryID: 81, BotUserID: 2001, UserID: 3001, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17, TopMessageID: 42, + Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || created { + t.Fatalf("duplicate created=%v err=%v", created, err) + } + if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID+1, action.QueryID, now); err != nil || found { + t.Fatalf("wrong bot found=%v err=%v", found, err) + } + got, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now) + if err != nil || !found || got.TopMessageID != 42 { + t.Fatalf("get=%+v found=%v err=%v", got, found, err) + } + if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, action.ExpiresAt); err != nil || found { + t.Fatalf("expired found=%v err=%v", found, err) + } +} + +func TestEphemeralCallbackActionBoundedHeapPrune(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + action := domain.EphemeralCallbackAction{ + QueryID: 82, BotUserID: 2001, UserID: 3001, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17, + Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if _, err := store.PruneExpiredEphemeralMessages(ctx, action.ExpiresAt, 1); err != nil { + t.Fatalf("prune err=%v", err) + } + shard := &store.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)] + shard.mu.RLock() + _, found := shard.actions[action.QueryID] + shard.mu.RUnlock() + if found { + t.Fatal("expired callback action survived bounded heap prune") + } +} + +func TestEphemeralReportStoreIdempotency(t *testing.T) { + store := NewEphemeralReportStore() + now := time.Unix(1_800_000_000, 0) + message := testEphemeralMessage(now) + message.ReceiverUserID = 3001 + report := domain.NewEphemeralAbuseReport(message.ReceiverUserID, "spam", "evidence", message, now) + if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || !created { + t.Fatalf("create=%v err=%v", created, err) + } + if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || created { + t.Fatalf("retry create=%v err=%v", created, err) + } + reports := store.Reports() + if len(reports) != 1 || reports[0].Evidence.Content.Message != message.Content.Message { + t.Fatalf("reports=%+v", reports) + } +} + +func testEphemeralMessage(now time.Time) domain.EphemeralMessage { + return domain.EphemeralMessage{ + ID: 17, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, + ReceiverUserID: 3001, + Date: int(now.Unix()), + RandomID: 99, + Content: domain.EphemeralContent{Message: "/private"}, + PayloadHash: sha256.Sum256([]byte("payload")), + Version: 1, + CreatedAt: now, + ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } +} + +func BenchmarkEphemeralMessageStoreParallelCreate(b *testing.B) { + store := NewEphemeralMessageStore() + base := time.Unix(1_800_000_000, 0) + ctx := context.Background() + var sequence atomic.Int64 + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + n := sequence.Add(1) + message := testEphemeralMessage(base) + message.ID = int(n%1_000_000) + 1 + message.Peer.ID += n + message.RandomID += n + message.PayloadHash = sha256.Sum256([]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}) + if _, _, err := store.CreateEphemeralMessage(ctx, message); err != nil { + b.Errorf("create: %v", err) + } + } + }) +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index a8d0b08d..f930af2e 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -91,6 +91,7 @@ func normalizeMemoryMessageIDs(ids []int) []int { func cloneMessage(msg domain.Message) domain.Message { msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + msg.Media = cloneRequestedPeerMedia(msg.Media) msg.ReplyTo = cloneMessageReply(msg.ReplyTo) msg.Forward = cloneMessageForward(msg.Forward) msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) @@ -99,13 +100,42 @@ func cloneMessage(msg domain.Message) domain.Message { return msg } -// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐 +// cloneRequestedPeerMedia isolates the immutable disclosure snapshot carried by +// messageActionRequestedPeer. Other media payloads retain their established +// copy behavior; this helper only deep-copies the newly mutable peer/photo slices. +func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia { + if media == nil { + return nil + } + clone := *media + if media.LivePhotoVideo != nil { + video := *media.LivePhotoVideo + video.FileReference = append([]byte(nil), media.LivePhotoVideo.FileReference...) + video.Attributes = append([]domain.DocumentAttribute(nil), media.LivePhotoVideo.Attributes...) + clone.LivePhotoVideo = &video + } + if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil { + return &clone + } + action := *media.ServiceAction + requested := *media.ServiceAction.RequestedPeer + requested.Peers = append([]domain.Peer(nil), requested.Peers...) + requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...) + for i := range requested.Details { + requested.Details[i].Photo = domain.ClonePhotoPtr(requested.Details[i].Photo) + } + action.RequestedPeer = &requested + clone.ServiceAction = &action + return &clone +} + +// cloneReplyMarkup 深拷 reply markup 快照:与 postgres 每盒独立 decode 对齐 // (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。 func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { if m == nil { return nil } - clone := domain.MessageReplyMarkup{} + clone := *m if m.Inline != nil { clone.Inline = make([][]domain.MarkupButton, len(m.Inline)) for i, row := range m.Inline { @@ -117,6 +147,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { clone.Inline[i] = cloneRow } } + if m.Keyboard != nil { + clone.Keyboard = make([][]domain.MarkupButton, len(m.Keyboard)) + for i, row := range m.Keyboard { + clone.Keyboard[i] = append([]domain.MarkupButton(nil), row...) + } + } return &clone } diff --git a/internal/store/memory/message_history.go b/internal/store/memory/message_history.go index 14692cc4..53bec30a 100644 --- a/internal/store/memory/message_history.go +++ b/internal/store/memory/message_history.go @@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom return out, nil } +// GetByUID resolves one owner's box row by the shared private message id. Callback delivery +// uses it to translate the clicker's box id to the bot's box id without scanning history. +func (s *MessageStore) GetByUID(_ context.Context, userID, uid int64) (domain.Message, bool, error) { + if userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + s.mu.RLock() + var found domain.Message + for _, msg := range s.m[userID] { + if msg.UID == uid { + found = cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(found) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + found.Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + break + } + } + s.mu.RUnlock() + if found.ID == 0 { + return domain.Message{}, false, nil + } + items := []domain.Message{found} + s.enrichPrivateMessagePolls(items, int(time.Now().Unix())) + return items[0], true, nil +} + func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { s.mu.RLock() messages := cloneMessages(s.m[userID]) @@ -235,11 +262,23 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d }) query := strings.ToLower(filter.Query) + peerIDs := make(map[int64]struct{}, len(filter.PeerIDs)) + for _, id := range filter.PeerIDs { + peerIDs[id] = struct{}{} + } base := make([]domain.Message, 0, len(messages)) for _, msg := range messages { if filter.HasPeer && msg.Peer != filter.Peer { continue } + if filter.RestrictPeerIDs { + if msg.Peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := peerIDs[msg.Peer.ID]; !ok { + continue + } + } if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { continue } diff --git a/internal/store/memory/message_markup_test.go b/internal/store/memory/message_markup_test.go new file mode 100644 index 00000000..09798bff --- /dev/null +++ b/internal/store/memory/message_markup_test.go @@ -0,0 +1,43 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestPrivateReplyKeyboardSurvivesBothBoxesAndHistory(t *testing.T) { + store := NewMessageStore(NewDialogStore()) + markup := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + SingleUse: true, + Persistent: true, + Placeholder: "Choose", + } + res, err := store.SendPrivateText(context.Background(), domain.SendPrivateTextRequest{ + SenderUserID: 10, RecipientUserID: 20, RandomID: 30, + Message: "pick", Date: 40, ReplyMarkup: markup, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertReplyKeyboard := func(name string, got *domain.MessageReplyMarkup) { + t.Helper() + if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 || + len(got.Keyboard[0]) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || + !got.SingleUse || !got.Persistent || got.Placeholder != "Choose" { + t.Fatalf("%s = %#v", name, got) + } + } + assertReplyKeyboard("sender", res.SenderMessage.ReplyMarkup) + assertReplyKeyboard("recipient", res.RecipientMessage.ReplyMarkup) + markup.Keyboard[0][0].Text = "mutated" + list, err := store.GetByIDs(context.Background(), 20, []int{res.RecipientMessage.ID}) + if err != nil || len(list.Messages) != 1 { + t.Fatalf("GetByIDs = %+v, %v", list, err) + } + assertReplyKeyboard("recipient history", list.Messages[0].ReplyMarkup) +} diff --git a/internal/store/memory/message_send.go b/internal/store/memory/message_send.go index 471ed9bd..4db7baac 100644 --- a/internal/store/memory/message_send.go +++ b/internal/store/memory/message_send.go @@ -84,7 +84,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate NoForwards: req.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, + Media: cloneRequestedPeerMedia(req.Media), ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, @@ -110,6 +110,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} recipient.Out = false recipient.ReplyTo = cloneMessageReply(recipientReply) + recipient.Media = cloneRequestedPeerMedia(sender.Media) // recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷 // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。 recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index fc365457..7c2ce4af 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient history", recipientHistory.Messages[0]) } +func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003} + req := domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 77, Peers: []domain.Peer{requestedPeer}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }}, + } + + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertSnapshot := func(name string, msg domain.Message) { + t.Helper() + if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil { + t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media) + } + action := msg.Media.ServiceAction.RequestedPeer + if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer || + len(action.Details) != 1 || action.Details[0].FirstName != "Shared" || + action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil || + len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 || + !action.NameRequested || !action.UsernameRequested || !action.PhotoRequested { + t.Fatalf("%s requested-peer=%+v", name, action) + } + } + assertSnapshot("sender", got.SenderMessage) + assertSnapshot("recipient", got.RecipientMessage) + + // Mutating either the request or one returned box must not alter the other + // box or the immutable store snapshot. + req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request" + req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1 + got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result" + got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2 + assertSnapshot("isolated recipient result", got.RecipientMessage) + + for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} { + history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10}) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("owner %d history=%+v err=%v", owner, history, err) + } + assertSnapshot("stored history", history.Messages[0]) + } +} + func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) { ctx := context.Background() messages := NewMessageStore() diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index b6ab41f6..2a6733f5 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -96,6 +96,10 @@ func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (do func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { s.mu.Lock() defer s.mu.Unlock() + return s.createCatalogRevisionLocked(write) +} + +func (s *StarGiftStore) createCatalogRevisionLocked(write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { giftID := write.GiftID if giftID == 0 { s.nextGiftID++ @@ -104,7 +108,21 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound } s.nextRevID++ - gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document} + gift := domain.StarGift{ + ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, + Title: write.Title, Sticker: write.Document, + Limited: write.Limited, SoldOut: write.SoldOut, Birthday: write.Birthday, + RequirePremium: write.RequirePremium, LimitedPerUser: write.LimitedPerUser, + PeerColorAvailable: write.PeerColorAvailable, Auction: write.Auction, + AvailabilityRemains: write.AvailabilityRemains, AvailabilityTotal: write.AvailabilityTotal, + AvailabilityResale: write.AvailabilityResale, FirstSaleDate: write.FirstSaleDate, + LastSaleDate: write.LastSaleDate, ResellMinStars: write.ResellMinStars, + ReleasedBy: write.ReleasedBy, PerUserTotal: write.PerUserTotal, + PerUserRemains: write.PerUserTotal, LockedUntilDate: write.LockedUntilDate, + AuctionSlug: write.AuctionSlug, GiftsPerRound: write.GiftsPerRound, + AuctionStartDate: write.AuctionStartDate, UpgradeVariants: write.UpgradeVariants, + Background: cloneStarGiftBackground(write.Background), + } s.catalog[giftID] = gift s.revisions[gift.RevisionID] = gift s.enabled[giftID] = write.Enabled @@ -113,6 +131,45 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil } +func cloneStarGiftBackground(value *domain.StarGiftBackground) *domain.StarGiftBackground { + if value == nil { + return nil + } + copy := *value + return © +} + +func (s *StarGiftStore) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = write.Catalog.GiftID + if collectibleWrite.GiftID == 0 { + collectibleWrite.GiftID = s.nextGiftID + 1 + } + if err := domain.ValidateStarGiftCollectibleWrite(collectibleWrite); err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + } + entry, err := s.createCatalogRevisionLocked(write.Catalog) + if err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + result := domain.StarGiftCatalogBundleResult{Catalog: entry} + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = entry.Gift.ID + revision, err := s.publishCollectibleRevisionLocked(collectibleWrite) + if err != nil { + return domain.StarGiftCatalogBundleResult{}, err + } + result.Collectible = &revision + result.Catalog.Gift = s.catalog[entry.Gift.ID] + } + return result, nil +} + func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -148,6 +205,10 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma } s.mu.Lock() defer s.mu.Unlock() + return s.publishCollectibleRevisionLocked(write) +} + +func (s *StarGiftStore) publishCollectibleRevisionLocked(write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) { if _, ok := s.catalog[write.GiftID]; !ok { return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound } @@ -156,7 +217,8 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1, UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal, SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true, - CreatedBy: write.Actor, + CreatedBy: write.Actor, + OfficialGiftID: write.OfficialGiftID, SourceManifestSHA256: append([]byte(nil), write.SourceManifestSHA256...), } if revision.ID == 1 { revision.ID = write.GiftID*1000 + 1 @@ -263,6 +325,25 @@ func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (m return out, nil } +func (s *StarGiftStore) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if owner.ID <= 0 || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.UniqueStarGift, 0, min(limit, len(s.uniqueByID))) + for _, gift := range s.uniqueByID { + if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" { + out = append(out, gift) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) { if !validSavedStarGift(gift) { return 0, domain.ErrStarGiftInvalid @@ -275,6 +356,7 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in gift.SavedID = gift.ID } gift.Converted = false + gift.LifecycleStatus = domain.StarGiftLifecycleActive s.gifts = append(s.gifts, gift) return gift.ID, nil } @@ -297,7 +379,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav defer s.mu.Unlock() matched := make([]domain.SavedStarGift, 0) for _, g := range s.gifts { - if g.Owner != owner || g.Converted { + if g.Owner != owner || !g.LifecycleStatus.Live() { continue } if filter.ExcludeUnsaved && g.Unsaved { @@ -329,28 +411,51 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav } matched = append(matched, g) } - sort.Slice(matched, func(i, j int) bool { return matched[i].ID > matched[j].ID }) + profileOrder := filter.CollectionID == 0 + sort.Slice(matched, func(i, j int) bool { + if profileOrder { + iPinned := matched[i].PinnedOrder > 0 + jPinned := matched[j].PinnedOrder > 0 + if iPinned != jPinned { + return iPinned + } + if iPinned && matched[i].PinnedOrder != matched[j].PinnedOrder { + return matched[i].PinnedOrder < matched[j].PinnedOrder + } + } + return matched[i].ID > matched[j].ID + }) page := domain.SavedStarGiftPage{Count: len(matched)} - cursor, hasCursor := domain.DecodeStarGiftCursor(offset) - out := make([]domain.SavedStarGift, 0, limit) + cursor, hasCursor := domain.DecodeSavedStarGiftListCursor(offset) + out := make([]domain.SavedStarGift, 0, limit+1) for _, g := range matched { - if hasCursor && g.ID >= cursor { - continue + if hasCursor { + if profileOrder { + if cursor.PinnedOrder > 0 { + if g.PinnedOrder > 0 && (g.PinnedOrder < cursor.PinnedOrder || + g.PinnedOrder == cursor.PinnedOrder && g.ID >= cursor.ID) { + continue + } + } else if g.PinnedOrder > 0 || g.ID >= cursor.ID { + continue + } + } else if g.ID >= cursor.ID { + continue + } } out = append(out, g) - if len(out) == limit { + if len(out) == limit+1 { break } } - if len(out) == limit { - // 还有更早的则给下一页游标。 - last := out[len(out)-1].ID - for _, g := range matched { - if g.ID < last { - page.NextOffset = domain.EncodeStarGiftCursor(last) - break - } + if len(out) > limit { + out = out[:limit] + last := out[len(out)-1] + pinnedOrder := 0 + if profileOrder { + pinnedOrder = last.PinnedOrder } + page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID) } page.Gifts = out return page, nil @@ -370,7 +475,7 @@ func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, re } var id int64 for _, gift := range s.gifts { - if savedStarGiftMatchesRef(gift, ref) && !gift.Converted { + if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() { id = gift.ID break } @@ -394,7 +499,7 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) s.mu.Lock() defer s.mu.Unlock() for _, g := range s.gifts { - if savedStarGiftMatchesRef(g, ref) { + if s.savedStarGiftMatchesRef(g, ref) { return g, true, nil } } @@ -409,7 +514,7 @@ func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, defer s.mu.Unlock() n := 0 for _, g := range s.gifts { - if g.Owner == owner && !g.Converted && !g.Unsaved { + if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved { n++ } } @@ -423,7 +528,7 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe s.mu.Lock() defer s.mu.Unlock() for i := range s.gifts { - if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted { + if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() { s.gifts[i].Unsaved = unsaved return true, nil } @@ -438,7 +543,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif s.mu.Lock() defer s.mu.Unlock() for i := range s.gifts { - if savedStarGiftMatchesRef(s.gifts[i], ref) { + if s.savedStarGiftMatchesRef(s.gifts[i], ref) { if s.gifts[i].UniqueGiftID != 0 { return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded } @@ -446,6 +551,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted } s.gifts[i].Converted = true + s.gifts[i].LifecycleStatus = domain.StarGiftLifecycleConverted s.gifts[i].Unsaved = true s.gifts[i].PinnedOrder = 0 for collectionIndex := range s.collections[ref.Owner] { @@ -640,7 +746,7 @@ func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []in } valid := false for _, gift := range s.gifts { - if gift.ID == id && gift.Owner == owner && !gift.Converted { + if gift.ID == id && gift.Owner == owner && gift.LifecycleStatus.Live() { valid = true break } @@ -707,6 +813,7 @@ func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.St func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision { out := in + out.SourceManifestSHA256 = append([]byte(nil), in.SourceManifestSHA256...) clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute { copy := make([]domain.StarGiftCollectibleAttribute, len(attributes)) for i, attribute := range attributes { @@ -747,10 +854,14 @@ func validStarGiftOwner(owner domain.Peer) bool { return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel) } -func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool { +func (s *StarGiftStore) savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool { if g.Owner != ref.Owner { return false } + if ref.Slug != "" { + uniqueID, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(ref.Slug))] + return ok && uniqueID != 0 && g.UniqueGiftID == uniqueID + } switch ref.Owner.Type { case domain.PeerTypeUser: return g.MsgID == ref.MsgID diff --git a/internal/store/memory/star_gift_identity_test.go b/internal/store/memory/star_gift_identity_test.go new file mode 100644 index 00000000..d3203ca2 --- /dev/null +++ b/internal/store/memory/star_gift_identity_test.go @@ -0,0 +1,41 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestSavedStarGiftIdentityDoesNotAcceptUpgradeMessageID(t *testing.T) { + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + store := NewStarGiftStore() + id, err := store.Create(ctx, domain.SavedStarGift{ + Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 115, + UniqueGiftID: 901, UpgradeMsgID: 116, + }) + if err != nil { + t.Fatalf("create saved gift: %v", err) + } + store.uniqueBySlug["official-8001-1"] = 901 + + canonical := domain.SavedStarGiftRef{Owner: owner, MsgID: 115} + if saved, found, err := store.GetByRef(ctx, canonical); err != nil || !found || saved.ID != id { + t.Fatalf("canonical identity: saved=%+v found=%v err=%v", saved, found, err) + } + wrong := domain.SavedStarGiftRef{Owner: owner, MsgID: 116} + if saved, found, err := store.GetByRef(ctx, wrong); err != nil || found { + t.Fatalf("upgrade message id resolved gift: saved=%+v found=%v err=%v", saved, found, err) + } + if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{wrong}); !errors.Is(err, domain.ErrStarGiftNotFound) { + t.Fatalf("upgrade message id resolve err=%v, want ErrStarGiftNotFound", err) + } + if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{ + canonical, + {Owner: owner, Slug: "official-8001-1"}, + }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("duplicate official identities err=%v", err) + } +} diff --git a/internal/store/memory/star_gift_profile_order_test.go b/internal/store/memory/star_gift_profile_order_test.go new file mode 100644 index 00000000..e7e008e0 --- /dev/null +++ b/internal/store/memory/star_gift_profile_order_test.go @@ -0,0 +1,69 @@ +package memory + +import ( + "context" + "slices" + "testing" + + "telesrv/internal/domain" +) + +func TestStarGiftProfilePinOrderAndPagination(t *testing.T) { + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + store := NewStarGiftStore() + ids := make([]int64, 4) + for i := range ids { + id, err := store.Create(ctx, domain.SavedStarGift{ + Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 100 + i, Date: 1700000000 + i, + }) + if err != nil { + t.Fatalf("create gift %d: %v", i, err) + } + ids[i] = id + } + + if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil { + t.Fatalf("set pinned: %v", err) + } + + want := []int64{ids[0], ids[2], ids[3], ids[1]} + var got []int64 + offset := "" + for pageNumber := 0; ; pageNumber++ { + page, err := store.ListByOwner(ctx, owner, false, offset, 1) + if err != nil { + t.Fatalf("list page %d: %v", pageNumber, err) + } + if page.Count != len(ids) || len(page.Gifts) != 1 { + t.Fatalf("page %d = %+v, want count=%d and one gift", pageNumber, page, len(ids)) + } + got = append(got, page.Gifts[0].ID) + if page.NextOffset == "" { + break + } + offset = page.NextOffset + } + if !slices.Equal(got, want) { + t.Fatalf("paged order = %v, want %v", got, want) + } + + if err := store.SetPinned(ctx, owner, nil); err != nil { + t.Fatalf("clear pinned: %v", err) + } + page, err := store.ListByOwner(ctx, owner, false, "", 10) + if err != nil { + t.Fatalf("list after clear: %v", err) + } + want = []int64{ids[3], ids[2], ids[1], ids[0]} + got = got[:0] + for _, gift := range page.Gifts { + got = append(got, gift.ID) + if gift.PinnedOrder != 0 { + t.Fatalf("gift %d pinned_order=%d after clear", gift.ID, gift.PinnedOrder) + } + } + if !slices.Equal(got, want) { + t.Fatalf("order after clear = %v, want %v", got, want) + } +} diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index f9814a18..17bd2c28 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "telesrv/internal/domain" + "time" ) // UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 @@ -66,7 +67,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.byID { - if u.Phone == phone { + if !u.Deleted && u.Phone == phone { return u, true, nil } } @@ -105,6 +106,9 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, out := make([]domain.User, 0, len(want)) seenIDs := map[int64]struct{}{} for _, u := range s.byID { + if u.Deleted { + continue + } if _, ok := want[u.Phone]; !ok { continue } @@ -126,7 +130,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.byID { - if strings.ToLower(u.Username) == username { + if !u.Deleted && strings.ToLower(u.Username) == username { return u, true, nil } } @@ -141,7 +145,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri s.mu.RLock() defer s.mu.RUnlock() for id, u := range s.byID { - if strings.ToLower(u.Username) == username && id != userID { + if !u.Deleted && strings.ToLower(u.Username) == username && id != userID { return false, nil } } @@ -161,7 +165,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ defer s.mu.RUnlock() users := make([]domain.User, 0) for _, u := range s.byID { - if u.ID == currentUserID { + if u.ID == currentUserID || u.Deleted { continue } if userMatchesSearch(u, query, phoneQuery) { @@ -183,7 +187,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUsernameNotOccupied } if usernameLower != "" { @@ -202,7 +206,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUsernameNotOccupied } u.FirstName = firstName @@ -216,7 +220,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.Birthday = birthday @@ -228,7 +232,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.PersonalChannelID = channelID @@ -242,7 +246,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok || !u.Bot { + if !ok || u.Deleted || !u.Bot { return 0, false } u.BotInfoVersion++ @@ -255,7 +259,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok || !u.Bot { + if !ok || u.Deleted || !u.Bot { return false } if setName { @@ -273,7 +277,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int) s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } if until < 0 { @@ -289,7 +293,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool) s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.Verified = verified @@ -306,7 +310,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) defer s.mu.Unlock() out := make([]domain.User, 0) for id, u := range s.byID { - if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now { + if u.Deleted || u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now { continue } u.PremiumUntil = 0 @@ -320,19 +324,20 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) return out, nil } -// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 -func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) { +// UpdateEmojiStatus 更新用户自定义 emoji status(零值表示清除)。 +func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } - if documentID == 0 { - until = 0 + if !status.Valid() { + return domain.User{}, domain.ErrStarGiftCollectibleInvalid } - u.EmojiStatusDocumentID = documentID - u.EmojiStatusUntil = until + u.EmojiStatusDocumentID = status.DocumentID + u.EmojiStatusUntil = status.Until + u.EmojiStatusCollectible = status.Collectible s.byID[userID] = u return u, nil } @@ -341,7 +346,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } if forProfile { @@ -360,7 +365,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.ErrUsernameNotOccupied } if lastSeenAt > u.LastSeenAt { @@ -405,6 +410,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error } u.ID = s.nextID s.nextID++ + if u.CreatedAt.IsZero() { + u.CreatedAt = time.Now().UTC() + } s.byID[u.ID] = u return u, nil } diff --git a/internal/store/postgres/account_lifecycle.go b/internal/store/postgres/account_lifecycle.go new file mode 100644 index 00000000..45373504 --- /dev/null +++ b/internal/store/postgres/account_lifecycle.go @@ -0,0 +1,738 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" +) + +// AccountLifecycleStore is the PostgreSQL implementation of the unified +// account tombstone, delayed deletion and deletion notification boundary. +type AccountLifecycleStore struct { + pool *pgxpool.Pool +} + +func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore { + return &AccountLifecycleStore{pool: pool} +} + +func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) { + if s == nil || s.pool == nil || userID == 0 { + return domain.AccountDeletionSnapshot{}, false, nil + } + u, found, err := NewUserStore(s.pool).ByID(ctx, userID) + if err != nil || !found { + return domain.AccountDeletionSnapshot{}, found, err + } + var snapshot = domain.AccountDeletionSnapshot{User: u} + if err := s.pool.QueryRow(ctx, ` +SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password), + COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at) +FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil { + return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err) + } + pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil) + if err != nil { + return domain.AccountDeletionSnapshot{}, false, err + } + if ok { + snapshot.Pending = &pending + } + return snapshot, true, nil +} + +func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) { + if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) { + return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err) + } + defer tx.Rollback(ctx) + if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err) + } + var deletedAt *time.Time + if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound + } + return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err) + } + if deletedAt != nil { + return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted + } + if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil { + return domain.AccountDeletionRequest{}, false, err + } else if ok { + if err := tx.Commit(ctx); err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err) + } + return existing, false, nil + } + row := tx.QueryRow(ctx, ` +INSERT INTO account_deletion_requests ( + user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest, + requested_at, execute_at, completed_at`, + req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason, + req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt) + pending, err := scanAccountDeletionRequest(row) + if err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err) + } + randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8])) + if randomID == 0 { + randomID = pending.ID + } + if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: domain.OfficialSystemUserID, + RecipientUserID: req.UserID, + RandomID: randomID, + Message: req.ServiceMessage, + Date: int(req.RequestedAt.Unix()), + }); err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err) + } + return pending, true, nil +} + +func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) { + if s == nil || s.pool == nil || userID == 0 { + return domain.AccountDeletionRequest{}, false, nil + } + return pendingAccountDeletion(ctx, s.pool, userID, digest[:]) +} + +func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) { + if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) { + return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err) + } + defer tx.Rollback(ctx) + if err := lockUsersForUpdate(ctx, tx, userID); err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err) + } + var lockedID int64 + if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountDeletionResult{}, domain.ErrUserNotFound + } + return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err) + } + u, found, err := NewUserStore(tx).ByID(ctx, userID) + if err != nil { + return domain.AccountDeletionResult{}, err + } + if !found { + return domain.AccountDeletionResult{}, domain.ErrUserNotFound + } + if u.Deleted { + return domain.AccountDeletionResult{User: u, Changed: false}, nil + } + if u.Bot || domain.IsSystemUserID(u.ID) { + return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden + } + due, err := accountDeletionStillDue(ctx, tx, u, source, now) + if err != nil { + return domain.AccountDeletionResult{}, err + } + if !due { + return domain.AccountDeletionResult{User: u, Changed: false}, nil + } + if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil { + return domain.AccountDeletionResult{}, err + } + if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil { + return domain.AccountDeletionResult{}, err + } + revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0) + if err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err) + } + if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil { + return domain.AccountDeletionResult{}, err + } + if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err) + } + reason = strings.TrimSpace(reason) + reason = truncateUTF8Bytes(reason, 1024) + if _, err := tx.Exec(ctx, ` +UPDATE users SET + phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '', + verified = false, support = false, last_seen_at = 0, + premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0, + emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb, + color_set = false, color = 0, color_background_emoji_id = 0, + profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0, + birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0, + deleted_at = $2, deletion_source = $3, deletion_reason = $4, + account_delete_at = NULL, updated_at = $2 +WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE account_deletion_requests +SET state = 'executed', completed_at = $2, updated_at = $2 +WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err) + } + u, found, err = NewUserStore(tx).ByID(ctx, userID) + if err != nil || !found { + if err == nil { + err = domain.ErrUserNotFound + } + return domain.AccountDeletionResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err) + } + return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil +} + +func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) { + if s == nil || s.pool == nil || userID == 0 || now.IsZero() { + return nil, domain.ErrAccountDeletionHashInvalid + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin cancel account deletion: %w", err) + } + defer tx.Rollback(ctx) + if err := lockUsersForUpdate(ctx, tx, userID); err != nil { + return nil, fmt.Errorf("lock cancel account deletion: %w", err) + } + pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:]) + if err != nil { + return nil, err + } + if !ok { + return nil, domain.ErrAccountDeletionHashInvalid + } + revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID) + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, ` +UPDATE account_deletion_requests +SET state = 'cancelled', completed_at = $2, updated_at = $2 +WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil { + return nil, fmt.Errorf("cancel account deletion request: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit cancel account deletion: %w", err) + } + return revoked, nil +} + +func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) { + if s == nil || s.pool == nil || limit <= 0 { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` +WITH candidates AS ( + SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority + FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1 + UNION ALL + SELECT id, 'account_ttl', account_delete_at, 2 + FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1 + UNION ALL + SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3 + FROM account_restrictions r JOIN users u ON u.id = r.user_id + WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1 + AND u.deleted_at IS NULL AND u.is_bot = false +), dedup AS ( + SELECT DISTINCT ON (user_id) user_id, source, due_at + FROM candidates ORDER BY user_id, priority, due_at +) +SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit) + if err != nil { + return nil, fmt.Errorf("list due account deletions: %w", err) + } + defer rows.Close() + out := make([]domain.AccountDeletionCandidate, 0) + for rows.Next() { + var c domain.AccountDeletionCandidate + var source string + if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil { + return nil, fmt.Errorf("scan due account deletion: %w", err) + } + c.Source = domain.AccountDeletionSource(source) + out = append(out, c) + } + return out, rows.Err() +} + +func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) { + if s == nil || s.pool == nil || limit <= 0 || lease <= 0 { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` +WITH claim AS ( + SELECT id FROM account_deletion_notifications + WHERE (status = 'pending' AND next_attempt_at <= $1) + OR (status = 'dispatching' AND lease_until <= $1) + ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2 +) +UPDATE account_deletion_notifications n +SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1 +FROM claim WHERE n.id = claim.id +RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease)) + if err != nil { + return nil, fmt.Errorf("claim account deletion notifications: %w", err) + } + defer rows.Close() + out := make([]domain.AccountDeletionNotification, 0) + for rows.Next() { + var n domain.AccountDeletionNotification + if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil { + return nil, fmt.Errorf("scan account deletion notification: %w", err) + } + out = append(out, n) + } + return out, rows.Err() +} + +func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error { + _, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications +SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now) + if err != nil { + return fmt.Errorf("complete account deletion notification: %w", err) + } + return nil +} + +type accountDeletionRowScanner interface { + Scan(dest ...any) error +} + +func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) { + var ( + r domain.AccountDeletionRequest + authKey int64 + state string + digest []byte + completedAt *time.Time + ) + if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest, + &r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil { + return domain.AccountDeletionRequest{}, err + } + if len(digest) != len(r.ConfirmHashDigest) { + return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest)) + } + copy(r.ConfirmHashDigest[:], digest) + r.RequesterAuthKeyID = authKeyIDFromInt64(authKey) + r.State = domain.AccountDeletionRequestState(state) + if completedAt != nil { + r.CompletedAt = *completedAt + } + return r, nil +} + +func pendingAccountDeletion(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) { + query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest, +requested_at, execute_at, completed_at FROM account_deletion_requests +WHERE user_id = $1 AND state = 'pending'` + args := []any{userID} + if digest != nil { + query += ` AND confirm_hash_digest = $2` + args = append(args, digest) + } + r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountDeletionRequest{}, false, nil + } + if err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err) + } + return r, true, nil +} + +func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) { + row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest, +requested_at, execute_at, completed_at FROM account_deletion_requests +WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest) + r, err := scanAccountDeletionRequest(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountDeletionRequest{}, false, nil + } + if err != nil { + return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err) + } + return r, true, nil +} + +func validAccountDeletionSource(source domain.AccountDeletionSource) bool { + switch source { + case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline, + domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry: + return true + default: + return false + } +} + +// accountDeletionStillDue closes the list-then-execute race for every scheduled +// source. The user row is already locked; source-specific facts are read and, +// where applicable, locked again immediately before destructive work begins. +// Manual sources are admission decisions made by the caller and have no +// independently mutable deadline to revalidate. +func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) { + switch source { + case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline: + return true, nil + case domain.AccountDeletionPasswordResetExpiry: + var requestID int64 + err := tx.QueryRow(ctx, ` +SELECT id FROM account_deletion_requests +WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2 +ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("revalidate pending account deletion: %w", err) + } + return true, nil + case domain.AccountDeletionAccountTTL: + return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil + case domain.AccountDeletionFreezeExpiry: + var frozen bool + var frozenUntil *time.Time + err := tx.QueryRow(ctx, ` +SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("revalidate frozen account deletion: %w", err) + } + return frozen && frozenUntil != nil && !frozenUntil.After(now), nil + default: + return false, domain.ErrAccountDeletionForbidden + } +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if maxBytes < 1 { + return "" + } + if len(value) <= maxBytes { + return value + } + cut := maxBytes + for cut > 0 && !utf8.ValidString(value[:cut]) { + cut-- + } + return value[:cut] +} + +func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error { + const maxAccountDeletionNotificationAudience = 4096 + _, err := tx.Exec(ctx, ` +INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id) +SELECT audience.user_id, $1 +FROM ( + SELECT user_id + FROM ( + SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity + FROM contacts WHERE user_id = $1 + UNION ALL + SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1 + UNION ALL + SELECT peer_id, 1, top_message_date + FROM dialogs WHERE user_id = $1 AND peer_type = 'user' + UNION ALL + SELECT user_id, 1, top_message_date + FROM dialogs WHERE peer_type = 'user' AND peer_id = $1 + ) candidates + GROUP BY user_id + ORDER BY min(priority), max(activity) DESC, user_id + LIMIT $2 +) audience +JOIN users u ON u.id = audience.user_id +WHERE audience.user_id <> $1 AND u.deleted_at IS NULL +ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience) + if err != nil { + return fmt.Errorf("enqueue account deletion notifications: %w", err) + } + return nil +} + +func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) { + id := authKeyIDToInt64(authKeyID) + if id == 0 { + return nil, nil + } + if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil { + return nil, err + } + var locked int64 + if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("lock password reset auth key: %w", err) + } + a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, ` +SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, + api_id, app_version, ip, password_pending, created_at, active_at +FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID)) + if err != nil { + return nil, fmt.Errorf("load password reset authorization: %w", err) + } + if !found { + return nil, nil + } + if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil { + return nil, err + } + return []domain.Authorization{a}, nil +} + +func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error { + // Leave shared private_messages/channel_messages and immutable transaction + // ledgers intact. Only the deleted user's private projections and settings are + // removed; other users continue to reference the tombstone sender. + statements := []string{ + `DELETE FROM account_privacy_rules WHERE owner_user_id = $1`, + `DELETE FROM account_reaction_settings WHERE user_id = $1`, + `DELETE FROM account_restrictions WHERE user_id = $1`, + `DELETE FROM account_settings WHERE user_id = $1`, + `DELETE FROM account_passwords WHERE user_id = $1`, + `DELETE FROM notify_settings WHERE owner_user_id = $1`, + `DELETE FROM passkey_credentials WHERE user_id = $1`, + `DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, + `DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`, + `DELETE FROM dialog_drafts WHERE user_id = $1`, + `DELETE FROM dialog_filter_settings WHERE user_id = $1`, + `DELETE FROM dialog_filters WHERE user_id = $1`, + `DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`, + `DELETE FROM chatlist_invites WHERE owner_user_id = $1`, + `DELETE FROM saved_dialog_pins WHERE user_id = $1`, + `DELETE FROM message_box_media WHERE owner_user_id = $1`, + `DELETE FROM private_media_category_counts WHERE owner_user_id = $1`, + `DELETE FROM message_boxes WHERE owner_user_id = $1`, + `DELETE FROM dialogs WHERE user_id = $1`, + `DELETE FROM dispatch_outbox WHERE target_user_id = $1`, + `DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`, + `DELETE FROM user_update_events WHERE user_id = $1`, + `DELETE FROM user_update_retention WHERE user_id = $1`, + `DELETE FROM user_update_watermarks WHERE user_id = $1`, + `DELETE FROM update_states WHERE user_id = $1`, + `DELETE FROM bootstrap_update_jobs WHERE user_id = $1`, + `DELETE FROM scheduled_messages WHERE owner_user_id = $1`, + `DELETE FROM quick_reply_messages WHERE owner_user_id = $1`, + `DELETE FROM quick_replies WHERE owner_user_id = $1`, + `DELETE FROM saved_music WHERE user_id = $1`, + `DELETE FROM user_sticker_collections WHERE owner_user_id = $1`, + `DELETE FROM user_sticker_sets WHERE owner_user_id = $1`, + `DELETE FROM user_recent_reactions WHERE user_id = $1`, + `DELETE FROM user_saved_reaction_tags WHERE user_id = $1`, + `DELETE FROM user_top_reactions WHERE user_id = $1`, + `DELETE FROM theme_user_installs WHERE user_id = $1`, + `DELETE FROM peer_translation_settings WHERE user_id = $1`, + `DELETE FROM ai_compose_tone_saves WHERE user_id = $1`, + `DELETE FROM ai_compose_tones WHERE owner_user_id = $1`, + `DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`, + `DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`, + `DELETE FROM business_connected_bots WHERE owner_user_id = $1`, + `DELETE FROM business_chat_links WHERE owner_user_id = $1`, + `DELETE FROM user_business_profiles WHERE user_id = $1`, + `DELETE FROM attach_menu_user_states WHERE user_id = $1`, + `DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`, + `DELETE FROM bot_user_permissions WHERE user_id = $1`, + `DELETE FROM login_code_message_deliveries WHERE user_id = $1`, + `DELETE FROM webview_custom_method_queries WHERE user_id = $1`, + `DELETE FROM webview_requested_buttons WHERE user_id = $1`, + `DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, + `DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`, + `DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`, + `DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`, + `DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`, + `DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, + `DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`, + `DELETE FROM group_call_participants WHERE user_id = $1`, + `DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`, + `DELETE FROM channel_boost_slots WHERE user_id = $1`, + `DELETE FROM channel_invite_importers WHERE user_id = $1`, + `DELETE FROM channel_topic_read WHERE user_id = $1`, + `DELETE FROM channel_unread_mentions WHERE user_id = $1`, + `DELETE FROM channel_unread_mention_index WHERE user_id = $1`, + `DELETE FROM channel_dialogs WHERE user_id = $1`, + `DELETE FROM user_channel_member_index WHERE user_id = $1`, + `DELETE FROM account_deletion_notifications WHERE target_user_id = $1`, + `DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`, + `DELETE FROM upload_parts WHERE owner_user_id = $1`, + `DELETE FROM encrypted_files WHERE owner_user_id = $1`, + } + for _, statement := range statements { + if _, err := tx.Exec(ctx, statement, userID); err != nil { + return fmt.Errorf("purge deleted account private state (%s): %w", statement, err) + } + } + if _, err := tx.Exec(ctx, ` +WITH changed AS ( + UPDATE channel_members + SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3 + WHERE user_id = $1 AND status = 'active' + RETURNING channel_id, role +), counts AS ( + SELECT channel_id, count(*) AS participants, + count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins + FROM changed GROUP BY channel_id +) +UPDATE channels c +SET participants_count = GREATEST(0, c.participants_count - counts.participants::int), + admins_count = GREATEST(0, c.admins_count - counts.admins::int), + updated_at = $3 +FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil { + return fmt.Errorf("leave deleted account channels: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE secret_chats SET state = 'discarded', history_deleted = true, + g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0 +WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil { + return fmt.Errorf("discard deleted account secret chats: %w", err) + } + return nil +} + +func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error { + nowUnix := int(now.Unix()) + rows, err := tx.Query(ctx, ` +SELECT id, buyer_user_id, currency, amount +FROM star_gift_offers +WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending' +ORDER BY id FOR UPDATE`, userID) + if err != nil { + return fmt.Errorf("lock deleted account gift offers: %w", err) + } + type offer struct { + id, buyer, amount int64 + currency string + } + offers := make([]offer, 0) + for rows.Next() { + var o offer + if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil { + rows.Close() + return fmt.Errorf("scan deleted account gift offer: %w", err) + } + offers = append(offers, o) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, o := range offers { + var balance int64 + if o.currency == "XTR" { + if err := tx.QueryRow(ctx, ` +INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2) +ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now() +RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil { + return fmt.Errorf("refund deleted account stars offer: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions +(user_id, peer_type, peer_id, amount, reason, title, description, date) +VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil { + return fmt.Errorf("record deleted account stars refund: %w", err) + } + } else { + if err := tx.QueryRow(ctx, ` +INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2) +ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now() +RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil { + return fmt.Errorf("refund deleted account TON offer: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions +(user_id, amount_nanoton, reason, peer_type, peer_id, date) +VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil { + return fmt.Errorf("record deleted account TON refund: %w", err) + } + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers +SET status = 'cancelled', resolved_at = $2, balance_after = $3 +WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil { + return fmt.Errorf("cancel deleted account gift offer: %w", err) + } + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers +SET status = 'cancelled', resolved_at = $2, balance_after = 0 +WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil { + return fmt.Errorf("cancel deleted buyer gift offers: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests +SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil { + return fmt.Errorf("fail deleted account withdrawals: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1 +WHERE bidder_user_id = $1 AND active = true`, userID); err != nil { + return fmt.Errorf("deactivate deleted account auction bids: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts +SET burned = true, owner_name = '', updated_at = $2 +WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil { + return fmt.Errorf("burn deleted account unique gifts: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts +SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0 +WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil { + return fmt.Errorf("burn deleted account saved gifts: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts +WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil { + return fmt.Errorf("delete deleted account regular gifts: %w", err) + } + var stars int64 + if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock deleted account stars balance: %w", err) + } + if stars != 0 { + if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil { + return fmt.Errorf("zero deleted account stars: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions +(user_id, peer_type, peer_id, amount, reason, title, description, date) +VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil { + return fmt.Errorf("record deleted account stars clearing: %w", err) + } + } + var ton int64 + if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock deleted account TON balance: %w", err) + } + if ton != 0 { + if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil { + return fmt.Errorf("zero deleted account TON: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions +(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil { + return fmt.Errorf("record deleted account TON clearing: %w", err) + } + } + return nil +} diff --git a/internal/store/postgres/account_lifecycle_integration_test.go b/internal/store/postgres/account_lifecycle_integration_test.go new file mode 100644 index 00000000..69565cbe --- /dev/null +++ b/internal/store/postgres/account_lifecycle_integration_test.go @@ -0,0 +1,289 @@ +package postgres + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "fmt" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + nonce := time.Now().UnixNano() + users := NewUserStore(pool) + deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me") + peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID}) + }) + + authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1) + authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2) + if _, err := pool.Exec(ctx, `INSERT INTO contacts +(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name) +VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil { + t.Fatalf("insert reverse contact: %v", err) + } + if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history", + }); err != nil { + t.Fatalf("send shared message: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil { + t.Fatalf("insert account settings: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil { + t.Fatalf("insert stars balance: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil { + t.Fatalf("insert TON balance: %v", err) + } + + lifecycle := NewAccountLifecycleStore(pool) + now := time.Now().UTC().Truncate(time.Second) + digestOne := sha256.Sum256([]byte("confirm-one")) + pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ + UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password", + ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one", + RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour), + }) + if err != nil || !created || pending.UserID != deleted.ID { + t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err) + } + if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID { + t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err) + } + revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute)) + if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne { + t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err) + } + if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found { + t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err) + } + if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found { + t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err) + } + + result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute)) + if err != nil { + t.Fatalf("execute account deletion: %v", err) + } + if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 { + t.Fatalf("deletion result = %+v", result) + } + if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found { + t.Fatalf("released phone found=%v err=%v", found, err) + } + if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" { + t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err) + } + if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil { + t.Fatal("deleted account profile mutation unexpectedly succeeded") + } + history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID}, + Limit: 10, + }) + if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID { + t.Fatalf("peer history after deletion = %+v err=%v", history, err) + } + var peerBoxes, settings, contacts, notifications int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(¬ifications); err != nil { + t.Fatal(err) + } + if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 { + t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications) + } + var stars, ton, starClear, tonClear int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil { + t.Fatal(err) + } + if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 { + t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear) + } +} + +func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + nonce := time.Now().UnixNano() + users := NewUserStore(pool) + ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User") + freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User") + pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User") + ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids) + _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids) + }) + now := time.Now().UTC().Truncate(time.Second) + if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions +(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url) +VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("due-pending")) + if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests +(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at) +VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10) + if err != nil { + t.Fatal(err) + } + sources := make(map[int64]domain.AccountDeletionSource, len(candidates)) + for _, candidate := range candidates { + sources[candidate.UserID] = candidate.Source + } + if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry { + t.Fatalf("due sources = %+v", sources) + } + seen := now.Add(time.Hour) + if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil { + t.Fatal(err) + } + lifecycle := NewAccountLifecycleStore(pool) + if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed { + t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err) + } + if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil { + t.Fatal(err) + } + if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed { + t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err) + } + if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil { + t.Fatal(err) + } + if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed { + t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err) + } + var deadline time.Time + if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil { + t.Fatal(err) + } + if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second { + t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want) + } + if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30) +ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil { + t.Fatal(err) + } + if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second { + t.Fatalf("custom TTL deadline=%v want=%v", deadline, want) + } +} + +func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock") + t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) }) + if _, err := pool.Exec(ctx, `INSERT INTO account_passwords +(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b) +VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil { + t.Fatal(err) + } + var initial, afterChallenge, afterPassword time.Time + if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil { + t.Fatal(err) + } + if !afterChallenge.Equal(initial) { + t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge) + } + if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil { + t.Fatal(err) + } + if !afterPassword.After(afterChallenge) { + t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword) + } +} + +func TestTruncateAccountDeletionReasonUTF8(t *testing.T) { + got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024) + if !utf8.ValidString(got) || len(got) > 1024 { + t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got)) + } + if got == "" { + t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason") + } +} + +func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte { + t.Helper() + var id [8]byte + var value [256]byte + if _, err := rand.Read(id[:]); err != nil { + t.Fatal(err) + } + id[0] = marker + if _, err := rand.Read(value[:]); err != nil { + t.Fatal(err) + } + if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil { + t.Fatalf("save lifecycle auth key: %v", err) + } + if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil { + t.Fatalf("bind lifecycle authorization: %v", err) + } + return id +} diff --git a/internal/store/postgres/bot_integration_test.go b/internal/store/postgres/bot_integration_test.go index cd3c5c4a..12bdf74f 100644 --- a/internal/store/postgres/bot_integration_test.go +++ b/internal/store/postgres/bot_integration_test.go @@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) { if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory { t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory) } + requestedButton := domain.BotRequestedWebViewButton{ + WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID, + ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), + } + t.Cleanup(func() { + _ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID) + }) + if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil { + t.Fatalf("save requested button: %v", err) + } + storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID) + if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested { + t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err) + } if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can { t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err) } diff --git a/internal/store/postgres/bot_miniapp.go b/internal/store/postgres/bot_miniapp.go index 6e1b99f3..0f9d2c9b 100644 --- a/internal/store/postgres/bot_miniapp.go +++ b/internal/store/postgres/bot_miniapp.go @@ -416,16 +416,29 @@ func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() { return domain.ErrBotRequestedButtonInvalid } - _, err := s.db.Exec(ctx, ` -INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + peerFilter, err := json.Marshal(button.PeerFilter) + if err != nil { + return domain.ErrBotRequestedButtonInvalid + } + _, err = s.db.Exec(ctx, ` +INSERT INTO webview_requested_buttons ( + webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, + peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (webapp_req_id) DO UPDATE SET button_id=EXCLUDED.button_id, text=EXCLUDED.text, peer_type=EXCLUDED.peer_type, max_quantity=EXCLUDED.max_quantity, + peer_filter=EXCLUDED.peer_filter, + name_requested=EXCLUDED.name_requested, + username_requested=EXCLUDED.username_requested, + photo_requested=EXCLUDED.photo_requested, expires_at=EXCLUDED.expires_at`, - button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt) + button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, + button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested, + button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt) if err != nil { return fmt.Errorf("save requested webview button: %w", err) } @@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) { _, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`) var button domain.BotRequestedWebViewButton + var peerFilter []byte err := s.db.QueryRow(ctx, ` -SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at +SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, + peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`, botUserID, userID, webAppReqID). - Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt) + Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, + &button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter, + &button.NameRequested, &button.UsernameRequested, &button.PhotoRequested, + &button.CreatedAt, &button.ExpiresAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotRequestedWebViewButton{}, false, nil } return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err) } + if string(peerFilter) != "{}" && string(peerFilter) != "null" { + if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil { + return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err) + } + } return button, true, nil } diff --git a/internal/store/postgres/botapi_update.go b/internal/store/postgres/botapi_update.go index c69b96de..d6df7017 100644 --- a/internal/store/postgres/botapi_update.go +++ b/internal/store/postgres/botapi_update.go @@ -1,8 +1,11 @@ package postgres import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "time" "github.com/jackc/pgx/v5" @@ -20,17 +23,331 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore { return &BotAPIUpdateStore{db: db} } +func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error { + if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 { + return fmt.Errorf("invalid bot api webhook") + } + var allowed []string + if len(config.AllowedUpdates) > 0 { + allowed = make([]string, 0, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + if kind != "" { + allowed = append(allowed, string(kind)) + } + } + } + if _, err := s.db.Exec(ctx, ` +WITH policy AS ( + SELECT CASE WHEN $6::boolean THEN $5::text[] + ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1) + END AS allowed_updates +), configured AS ( + INSERT INTO bot_api_webhooks ( + bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at, + delivery_owner, delivery_expires_at, updated_at + ) + SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now() + FROM policy + ON CONFLICT (bot_user_id) DO UPDATE + SET url = EXCLUDED.url, + secret_token = EXCLUDED.secret_token, + max_connections = EXCLUDED.max_connections, + allowed_updates = EXCLUDED.allowed_updates, + failure_count = 0, + last_error_date = 0, + last_error_message = '', + next_attempt_at = now(), + delivery_owner = '', + delivery_expires_at = NULL, + updated_at = now() + RETURNING bot_user_id +), boundary AS ( + SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states ( + bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized +) +SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean +FROM boundary, configured, policy +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = CASE WHEN $7::boolean + THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id) + ELSE bot_api_update_states.confirmed_update_id + END, + allowed_updates = EXCLUDED.allowed_updates, + cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized, + updated_at = now() +`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed, + config.AllowedUpdatesSet, dropPending); err != nil { + return fmt.Errorf("set bot api webhook: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error { + if botUserID <= 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id +), boundary AS ( + SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, confirmed_update_id, $2::boolean +FROM boundary +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = CASE WHEN $2::boolean + THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id) + ELSE bot_api_update_states.confirmed_update_id + END, + cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized, + updated_at = now() +`, botUserID, dropPending); err != nil { + return fmt.Errorf("delete bot api webhook: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) { + config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, ` +SELECT bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at +FROM bot_api_webhooks +WHERE bot_user_id = $1 +`, botUserID)) + if err == pgx.ErrNoRows { + return domain.BotAPIWebhook{}, false, nil + } + if err != nil { + return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err) + } + return config, true, nil +} + +func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + rows, err := s.db.Query(ctx, ` +SELECT bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at +FROM bot_api_webhooks +WHERE next_attempt_at <= now() + AND (delivery_owner = '' OR delivery_expires_at <= now()) +ORDER BY next_attempt_at, bot_user_id +LIMIT $1 +`, limit) + if err != nil { + return nil, fmt.Errorf("list due bot api webhooks: %w", err) + } + defer rows.Close() + out := make([]domain.BotAPIWebhook, 0, limit) + for rows.Next() { + config, err := scanBotAPIWebhook(rows) + if err != nil { + return nil, err + } + out = append(out, config) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list due bot api webhook rows: %w", err) + } + return out, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api webhook lease") + } + var acquiredOwner string + err := s.db.QueryRow(ctx, ` +UPDATE bot_api_webhooks +SET delivery_owner = $2, + delivery_expires_at = now() + make_interval(secs => $3), + updated_at = now() +WHERE bot_user_id = $1 + AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now()) +RETURNING delivery_owner +`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("acquire bot api webhook lease: %w", err) + } + return acquiredOwner == owner, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner); err != nil { + return fmt.Errorf("release bot api webhook lease: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error { + if len(message) > 512 { + message = message[:512] + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET failure_count = failure_count + 1, + last_error_date = EXTRACT(EPOCH FROM now())::integer, + last_error_message = $3, + next_attempt_at = $4, + delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner, message, nextAttempt); err != nil { + return fmt.Errorf("record bot api webhook failure: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error { + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET failure_count = 0, last_error_date = 0, last_error_message = '', + next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner, nextAttempt); err != nil { + return fmt.Errorf("record bot api webhook success: %w", err) + } + return nil +} + +func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) { + var config domain.BotAPIWebhook + var allowed []string + if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed, + &config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil { + return domain.BotAPIWebhook{}, err + } + if allowed != nil { + config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed)) + for _, kind := range allowed { + config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind)) + } + } + return config, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api poll lease") + } + var acquiredOwner string + err := s.db.QueryRow(ctx, ` +INSERT INTO bot_api_update_states ( + bot_user_id, confirmed_update_id, poll_owner, poll_expires_at +) VALUES ($1, 0, $2, now() + make_interval(secs => $3)) +ON CONFLICT (bot_user_id) DO UPDATE +SET poll_owner = EXCLUDED.poll_owner, + poll_expires_at = EXCLUDED.poll_expires_at, + updated_at = now() +WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner + OR bot_api_update_states.poll_expires_at IS NULL + OR bot_api_update_states.poll_expires_at <= now() +RETURNING poll_owner +`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("acquire bot api poll lease: %w", err) + } + return acquiredOwner == owner, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_update_states +SET poll_owner = '', poll_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND poll_owner = $2 +`, botUserID, owner); err != nil { + return fmt.Errorf("release bot api poll lease: %w", err) + } + return nil +} + func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) { if err := validateBotAPIUpdateRequest(req); err != nil { return domain.BotAPIUpdate{}, false, err } + var callbackQueryID, callbackUserID, callbackChatInstance int64 + var callbackInlineDCID, callbackInlineMessageID int + var callbackInlineOwnerID, callbackInlineAccessHash int64 + var callbackData []byte + var ephemeralPayload []byte + if req.Callback != nil { + callbackQueryID = req.Callback.ID + callbackUserID = req.Callback.UserID + callbackChatInstance = req.Callback.ChatInstance + callbackData = req.Callback.Data + if req.Callback.InlineMessage != nil { + callbackInlineDCID = req.Callback.InlineMessage.DCID + callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID + callbackInlineMessageID = req.Callback.InlineMessage.ID + callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash + } + } + if req.Ephemeral != nil { + var err error + ephemeralPayload, err = json.Marshal(req.Ephemeral) + if err != nil { + return domain.BotAPIUpdate{}, false, fmt.Errorf("marshal bot api ephemeral payload: %w", err) + } + } row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` -INSERT INTO bot_api_updates ( - bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date -) VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING -RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date -`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date)) +WITH inserted AS ( + INSERT INTO bot_api_updates ( + bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload +) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb +WHERE NOT EXISTS ( + SELECT 1 + FROM bot_api_update_states + WHERE bot_user_id = $1 + AND allowed_updates IS NOT NULL + AND NOT ($2::text = ANY(allowed_updates)) +) + ON CONFLICT DO NOTHING + RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload +), wake_webhook AS ( + UPDATE bot_api_webhooks + SET next_attempt_at = now(), updated_at = now() + WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted) + RETURNING bot_user_id +) +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload +FROM inserted +`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date, + callbackQueryID, callbackUserID, callbackChatInstance, callbackData, + callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload)) if err == nil { return row, true, nil } @@ -38,21 +355,75 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err) } row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` -SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload FROM bot_api_updates WHERE bot_user_id = $1 AND update_kind = $2 - AND peer_type = $3 - AND peer_id = $4 - AND message_id = $5 - AND source_pts = $6 -`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts)) + AND ( + (update_kind = 'callback_query' AND callback_query_id = $7) + OR + (update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND ( + (ephemeral_payload IS NULL AND $8::jsonb IS NULL AND source_pts = $6) + OR (ephemeral_payload = $8::jsonb) + )) + ) +`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID, ephemeralPayload)) if err != nil { + if err == pgx.ErrNoRows { + return domain.BotAPIUpdate{}, false, nil + } return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err) } return row, false, nil } +func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) { + if botUserID == 0 || tail <= 0 { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 100 + } + rows, err := s.db.Query(ctx, ` +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload +FROM ( + SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload + FROM bot_api_updates + WHERE bot_user_id = $1 + AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0) + ORDER BY id DESC + LIMIT $2 +) AS tail_updates +ORDER BY id +LIMIT $3 +`, botUserID, tail, limit) + if err != nil { + return nil, fmt.Errorf("list bot api tail updates: %w", err) + } + defer rows.Close() + out := make([]domain.BotAPIUpdate, 0, limit) + for rows.Next() { + item, err := scanBotAPIUpdateRows(rows) + if err != nil { + return nil, err + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list bot api tail update rows: %w", err) + } + return out, nil +} + func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) { if botUserID == 0 { return nil, nil @@ -64,7 +435,10 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr limit = 100 } rows, err := s.db.Query(ctx, ` -SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload FROM bot_api_updates WHERE bot_user_id = $1 AND id >= $2 ORDER BY id @@ -93,23 +467,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID, return nil } if _, err := s.db.Exec(ctx, ` -INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id) -VALUES ($1, $2) +WITH bounded AS ( + SELECT COALESCE(MAX(id), 0) AS max_update_id, + LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, confirmed_update_id, true +FROM bounded ON CONFLICT (bot_user_id) DO UPDATE -SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id), +SET confirmed_update_id = GREATEST( + bot_api_update_states.confirmed_update_id, + CASE + WHEN $2::bigint > (SELECT max_update_id FROM bounded) + AND bot_api_update_states.cursor_initialized + THEN bot_api_update_states.confirmed_update_id + ELSE EXCLUDED.confirmed_update_id + END + ), + cursor_initialized = true, updated_at = now() -WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id `, botUserID, confirmedUpdateID); err != nil { return fmt.Errorf("confirm bot api updates: %w", err) } return nil } +func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error { + if botUserID == 0 { + return nil + } + var values []string + if len(allowed) > 0 { + values = make([]string, 0, len(allowed)) + for _, kind := range allowed { + if kind != "" { + values = append(values, string(kind)) + } + } + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates) +VALUES ($1, 0, $2::text[]) +ON CONFLICT (bot_user_id) DO UPDATE +SET allowed_updates = EXCLUDED.allowed_updates, + updated_at = now() +`, botUserID, values); err != nil { + return fmt.Errorf("set bot api allowed updates: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error { + if botUserID == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, COALESCE(MAX(id), 0), true +FROM bot_api_updates +WHERE bot_user_id = $1 +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id), + cursor_initialized = true, + updated_at = now() +`, botUserID); err != nil { + return fmt.Errorf("drop pending bot api updates: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) { + if botUserID == 0 { + return 0, nil + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*) +FROM bot_api_updates +WHERE bot_user_id = $1 + AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0) +`, botUserID).Scan(&count); err != nil { + return 0, fmt.Errorf("count pending bot api updates: %w", err) + } + return count, nil +} + // DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1): // 1. 已确认(id <= bot_api_update_states.confirmed_update_id)且入队超过 confirmedGrace 的行—— // 官方 Bot API 语义下确认即弃,getUpdates 的 fromID 恒 > confirmed,删除不影响任何读路径; // 宽限仅防御 offset 回拨调试场景。 -// 2. 按消息 date 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」 +// 2. 按队列 created_at 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」 // 语义,同时封顶 MTProto-only bot(从不调 getUpdates、无 state 行)成员身份带来的无界增长。 // // 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫, @@ -139,15 +588,15 @@ WHERE id IN ( total += int(tag.RowsAffected()) } if maxAge > 0 { - cutoff := time.Now().Add(-maxAge).Unix() - // 走 bot_api_updates_retention_idx(date, id)。 + cutoff := time.Now().Add(-maxAge) + // 走 bot_api_updates_created_retention_idx(created_at, id)。 tag, err := s.db.Exec(ctx, ` DELETE FROM bot_api_updates WHERE id IN ( SELECT id FROM bot_api_updates - WHERE date < $1 - ORDER BY date, id + WHERE created_at < $1 + ORDER BY created_at, id LIMIT $2 )`, cutoff, limit) if err != nil { @@ -187,28 +636,113 @@ type botAPIUpdateScanner interface { func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) { var item domain.BotAPIUpdate var kind, peerType string - if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil { + var callbackQueryID, callbackUserID, callbackChatInstance int64 + var callbackInlineDCID, callbackInlineMessageID int + var callbackInlineOwnerID, callbackInlineAccessHash int64 + var callbackData []byte + var ephemeralPayload []byte + if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date, + &callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData, + &callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash, + &ephemeralPayload); err != nil { return domain.BotAPIUpdate{}, err } item.Kind = domain.BotAPIUpdateKind(kind) item.Peer.Type = domain.PeerType(peerType) + if item.Kind == domain.BotAPIUpdateCallbackQuery { + item.Callback = &domain.BotCallbackQuery{ + ID: callbackQueryID, + BotUserID: item.BotUserID, + UserID: callbackUserID, + Peer: item.Peer, + MessageID: item.MessageID, + ChatInstance: callbackChatInstance, + Data: append([]byte(nil), callbackData...), + } + if callbackInlineMessageID > 0 { + item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash} + } + } + if len(ephemeralPayload) != 0 { + var payload domain.BotAPIEphemeralPayload + decoder := json.NewDecoder(bytes.NewReader(ephemeralPayload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: trailing JSON") + } + if err := validateBotAPIEphemeralPayload(item.BotUserID, item.Kind, item.Peer, item.MessageID, item.SourcePts, item.Date, &payload); err != nil { + return domain.BotAPIUpdate{}, err + } + item.Ephemeral = &payload + } return item, nil } func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { - if req.BotUserID == 0 || req.MessageID <= 0 { + if req.BotUserID == 0 { return fmt.Errorf("invalid bot api update") } - if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage { + if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery { return fmt.Errorf("invalid bot api update kind %q", req.Kind) } switch req.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: - if req.Peer.ID <= 0 { + if req.Peer.ID <= 0 || req.MessageID <= 0 { + return fmt.Errorf("invalid bot api update peer") + } + case "": + if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 { return fmt.Errorf("invalid bot api update peer") } default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if err := validateBotAPIEphemeralPayload(req.BotUserID, req.Kind, req.Peer, req.MessageID, req.SourcePts, req.Date, req.Ephemeral); err != nil { + return err + } + if req.Kind == domain.BotAPIUpdateCallbackQuery { + cb := req.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || + cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 || + len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api callback query") + } + inline := cb.InlineMessage + if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) { + return fmt.Errorf("invalid bot api inline callback query") + } + if req.MessageID > 0 && inline != nil { + return fmt.Errorf("ambiguous bot api callback query") + } + } else if req.Callback != nil { + return fmt.Errorf("unexpected bot api callback query") + } + return nil +} + +func validateBotAPIEphemeralPayload(botUserID int64, kind domain.BotAPIUpdateKind, peer domain.Peer, messageID, sourcePts, date int, payload *domain.BotAPIEphemeralPayload) error { + if payload == nil { + return nil + } + message := payload.Message + if payload.Validate() != nil || peer.Type != domain.PeerTypeChannel || message.ID != messageID || message.Peer != peer || + message.Expired(time.Unix(int64(date), 0)) || sourcePts != 0 { + return fmt.Errorf("invalid bot api ephemeral update") + } + if kind == domain.BotAPIUpdateCallbackQuery { + if message.SenderUserID != botUserID { + return fmt.Errorf("invalid bot api ephemeral callback target") + } + return nil + } + if kind != domain.BotAPIUpdateMessage && kind != domain.BotAPIUpdateEditedMessage { + return fmt.Errorf("invalid bot api ephemeral update kind") + } + if message.ReceiverUserID != botUserID { + return fmt.Errorf("invalid bot api ephemeral receiver") + } return nil } diff --git a/internal/store/postgres/botapi_update_integration_test.go b/internal/store/postgres/botapi_update_integration_test.go index 67b6dd5d..50b72cb5 100644 --- a/internal/store/postgres/botapi_update_integration_test.go +++ b/internal/store/postgres/botapi_update_integration_test.go @@ -1,6 +1,7 @@ package postgres import ( + "bytes" "context" "testing" "time" @@ -8,10 +9,292 @@ import ( "telesrv/internal/domain" ) +func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{ + AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot", + }) + if err != nil { + t.Fatalf("create bot user: %v", err) + } + clicker, err := users.Create(ctx, domain.User{ + AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker", + }) + if err != nil { + t.Fatalf("create callback user: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO bots (bot_user_id, owner_user_id, token_secret) +VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil { + t.Fatalf("seed bot: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + callback := &domain.BotCallbackQuery{ + ID: 880011, BotUserID: bot.ID, UserID: clicker.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17, + ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'}, + } + req := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, + Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback, + } + store := NewBotAPIUpdateStore(pool) + first, created, err := store.EnqueueBotAPIUpdate(ctx, req) + if err != nil || !created { + t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err) + } + again, created, err := store.EnqueueBotAPIUpdate(ctx, req) + if err != nil || created || again.ID != first.ID { + t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err) + } + items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) + if err != nil || len(items) != 1 { + t.Fatalf("list callback = %+v, %v", items, err) + } + got := items[0].Callback + if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID || + got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance || + !bytes.Equal(got.Data, callback.Data) { + t.Fatalf("callback round trip = %+v, want %+v", got, callback) + } +} + +func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"}) + if err != nil { + t.Fatal(err) + } + clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + s := NewBotAPIUpdateStore(pool) + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566} + callback := &domain.BotCallbackQuery{ + ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811, + Data: []byte{0, 1, 0xff}, InlineMessage: inline, + } + row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback, + }) + if err != nil || !created { + t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err) + } + items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100) + if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 || + items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline || + !bytes.Equal(items[0].Callback.Data, callback.Data) { + t.Fatalf("inline callback items=%#v err=%v", items, err) + } + + config := domain.BotAPIWebhook{ + BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret", + MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true, + } + if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err := s.BotAPIWebhook(ctx, bot.ID) + if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken || + stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 { + t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err) + } + config.URL = "https://example.test/reconfigured" + config.AllowedUpdates = nil + config.AllowedUpdatesSet = false + if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err = s.BotAPIWebhook(ctx, bot.ID) + if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery { + t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err) + } + if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { + t.Fatalf("first lease=%v err=%v", acquired, err) + } + if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { + t.Fatalf("second lease=%v err=%v", acquired, err) + } + if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired { + t.Fatal("stale webhook release removed active lease") + } + next := time.Now().Add(time.Hour) + if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil { + t.Fatal(err) + } + if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 { + t.Fatalf("idle due=%#v err=%v", due, err) + } + // A newly inserted allowed callback wakes the idle webhook in the same SQL statement. + callback2 := *callback + callback2.ID++ + callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677} + if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2, + }); err != nil || !created { + t.Fatalf("enqueue wake created=%v err=%v", created, err) + } + if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID { + t.Fatalf("woken due=%#v err=%v", due, err) + } + if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil { + t.Fatal(err) + } + if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found { + t.Fatalf("webhook after delete found=%v err=%v", found, err) + } + if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 { + t.Fatalf("pending after delete/drop=%d err=%v", pending, err) + } +} + +func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool) + if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { + t.Fatalf("first acquire=%v err=%v", acquired, err) + } + if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { + t.Fatalf("cross-instance acquire=%v err=%v", acquired, err) + } + if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired { + t.Fatal("stale release removed active poll lease") + } + if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil { + t.Fatal(err) + } + if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired { + t.Fatalf("successor acquire=%v err=%v", acquired, err) + } +} + +func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + bot, err := users.Create(ctx, domain.User{ + AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot", + }) + if err != nil { + t.Fatalf("create bot user: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil { + t.Fatalf("seed bot: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + s := NewBotAPIUpdateStore(pool) + enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) { + t.Helper() + row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: kind, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1}, + MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("enqueue %s/%d: %v", kind, messageID, err) + } + return row, created + } + for id := 1; id <= 3; id++ { + if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created { + t.Fatalf("initial message %d was not created", id) + } + } + if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil { + t.Fatalf("set allowed updates: %v", err) + } + if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 { + t.Fatalf("filtered row=%+v created=%v", row, created) + } + lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5) + if !created { + t.Fatal("allowed edit was filtered") + } + if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { + t.Fatalf("initialize external cursor: %v", err) + } + confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID) + if err != nil || !found || confirmed != lastBeforeBaseline.ID { + t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID) + } + pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6) + if !created { + t.Fatal("post-baseline edit was filtered") + } + if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { + t.Fatalf("repeat external cursor: %v", err) + } + confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID) + if confirmed != lastBeforeBaseline.ID { + t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID) + } + tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100) + if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID { + t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID) + } + if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 { + t.Fatalf("pending count=%d err=%v", count, err) + } + if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil { + t.Fatalf("drop pending: %v", err) + } + if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 { + t.Fatalf("pending after drop=%d err=%v", count, err) + } +} + // TestBotAPIUpdateRetention 锁定 H1 场景矩阵: // - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留; -// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot); -// - 未确认 + date 在保留期内 → 留; +// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot); +// - 未确认 + created_at 在保留期内 → 留; // - 删除后 getUpdates 读路径(fromID > confirmed)不受影响。 func TestBotAPIUpdateRetention(t *testing.T) { pool := testPool(t) @@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { s := NewBotAPIUpdateStore(pool) now := time.Now().Unix() - stale := now - int64((48 * time.Hour).Seconds()) enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate { t.Helper() row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ @@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删 confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留 unconfirmedFresh := enqueue(confirmedBot, 3, now) - expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删 + expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删 freshNoState := enqueue(mtprotoOnlyBot, 5, now) if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil { @@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { "UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil { t.Fatalf("backdate confirmed row: %v", err) } + if _, err := pool.Exec(ctx, + "UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil { + t.Fatalf("backdate expired row: %v", err) + } deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000) if err != nil { @@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { // 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行; // 精确归属由下方 remaining 断言保证。 if deleted < 2 { - t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted) + t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted) } remaining := map[int64]bool{} @@ -120,3 +406,70 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID) } } + +func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"}) + if err != nil { + t.Fatal(err) + } + human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'ephemeral-secret')`, bot.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901} + message := domain.EphemeralMessage{ + ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID, + Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + store := NewBotAPIUpdateStore(pool) + request := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer, + MessageID: message.ID, Date: message.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(message), + } + first, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || first.Ephemeral == nil { + t.Fatalf("first=%+v created=%v err=%v", first, created, err) + } + var leakedPrivateRoutingState bool + if err := pool.QueryRow(ctx, ` + SELECT (ephemeral_payload -> 'Message') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ] + FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil { + t.Fatal(err) + } + if leakedPrivateRoutingState { + t.Fatal("durable Bot API envelope contains private ephemeral routing fields") + } + if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID { + t.Fatalf("replay=%+v created=%v err=%v", replay, created, err) + } + message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited" + request.Kind = domain.BotAPIUpdateEditedMessage + request.Ephemeral = domain.NewBotAPIEphemeralPayload(message) + second, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || second.ID <= first.ID { + t.Fatalf("second=%+v created=%v err=%v", second, created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) + if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil || + rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" { + t.Fatalf("rows=%+v err=%v", rows, err) + } +} diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go index c3a5363a..2104c4ee 100644 --- a/internal/store/postgres/channel_core.go +++ b/internal/store/postgres/channel_core.go @@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) if err != nil { return nil, err } + parentIDs := make([]int64, 0, len(channels)) + for _, channel := range channels { + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + parentIDs = append(parentIDs, channel.LinkedMonoforumID) + } + } + parents, err := listChannelsByIDs(ctx, s.db, parentIDs) + if err != nil { + return nil, err + } + parentsByID := make(map[int64]domain.Channel, len(parents)) + for _, parent := range parents { + parentsByID[parent.ID] = parent + } linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining) if err != nil { return nil, err @@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) } continue } + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + if parent, ok := parentsByID[channel.LinkedMonoforumID]; ok && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID { + member := syntheticMonoforumUserMember(channel, viewerUserID) + views[channel.ID] = domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + } + continue + } + } if !publicPreviewableChannel(channel) { continue } @@ -472,7 +497,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) { func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any { return []any{ &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights, reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, diff --git a/internal/store/postgres/channel_groupcall.go b/internal/store/postgres/channel_groupcall.go index fc08b6e7..55fb1f20 100644 --- a/internal/store/postgres/channel_groupcall.go +++ b/internal/store/postgres/channel_groupcall.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" ) @@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se _ = tx.Rollback(ctx) } }() + if err := s.appendStarGiftAdminLogTx(ctx, tx, channelID, senderUserID, savedID, date, action); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit star gift admin log: %w", err) + } + committed = true + return nil +} + +// appendStarGiftAdminLogTx is the aggregate-local form used when the saved gift, +// inventory/balance mutation and Recent Actions entry must commit together. +func (s *ChannelStore) appendStarGiftAdminLogTx(ctx context.Context, tx pgx.Tx, channelID, senderUserID, savedID int64, date int, action domain.ChannelMessageAction) error { + if channelID == 0 || senderUserID == 0 || savedID <= 0 { + return domain.ErrChannelInvalid + } channel, err := getChannelByID(ctx, tx, channelID) if err != nil { return err @@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se } action = channelServiceActionForMessage(channelID, messageID, action) msg := domain.ChannelMessage{ - ChannelID: channelID, - ID: messageID, - SenderUserID: senderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, - Date: date, - Post: channel.Broadcast, - Action: &action, - Pts: channel.Pts, + ChannelID: channelID, ID: messageID, SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date, + Post: channel.Broadcast, Action: &action, Pts: channel.Pts, } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: senderUserID, - Date: date, - Type: domain.ChannelAdminLogSendMessage, - Message: &msg, - }); err != nil { - return err - } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit star gift admin log: %w", err) - } - committed = true - return nil + return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, UserID: senderUserID, Date: date, + Type: domain.ChannelAdminLogSendMessage, Message: &msg, + }) } func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index 338add5b..c3650cab 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, limit = domain.MaxSynchronousChannelDialogFanout } rows, err := s.db.Query(ctx, ` -SELECT channel_id -FROM user_channel_member_index -WHERE user_id = $1 - AND status = 'active' - AND NOT deleted - AND channel_id > $2 +WITH visible_channels AS ( + SELECT channel_id + FROM user_channel_member_index + WHERE user_id = $1 AND status = 'active' AND NOT deleted + UNION + SELECT mono.id + FROM channels mono + JOIN channels parent ON parent.id = mono.linked_monoforum_id + AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id + WHERE mono.monoforum AND NOT mono.deleted + AND (EXISTS ( + SELECT 1 FROM channel_members admin + WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin') + ) OR EXISTS ( + SELECT 1 FROM channel_messages message + WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted + )) +) +SELECT channel_id FROM visible_channels +WHERE channel_id > $2 ORDER BY channel_id LIMIT $3`, userID, afterChannelID, limit) if err != nil { @@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI limit = domain.MaxChannelDifferenceLimit } rows, err := s.db.Query(ctx, ` -SELECT i.channel_id, c.pts -FROM user_channel_member_index i -JOIN channels c ON c.id = i.channel_id AND NOT c.deleted -JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id -WHERE i.user_id = $1 - AND i.status = 'active' - AND NOT i.deleted - AND i.channel_id > $3 +WITH visible_channels AS ( + SELECT channel_id + FROM user_channel_member_index + WHERE user_id = $1 AND status = 'active' AND NOT deleted + UNION + SELECT mono.id + FROM channels mono + JOIN channels parent ON parent.id = mono.linked_monoforum_id + AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id + WHERE mono.monoforum AND NOT mono.deleted + AND (EXISTS ( + SELECT 1 FROM channel_members admin + WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin') + ) OR EXISTS ( + SELECT 1 FROM channel_messages message + WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted + )) +) +SELECT visible.channel_id, c.pts +FROM visible_channels visible +JOIN channels c ON c.id = visible.channel_id AND NOT c.deleted +JOIN channel_update_checkpoints cp ON cp.channel_id = visible.channel_id +WHERE visible.channel_id > $3 AND cp.latest_event_date > $2 -ORDER BY i.channel_id ASC +ORDER BY visible.channel_id ASC LIMIT $4`, userID, sinceDate, afterChannelID, limit) if err != nil { return nil, fmt.Errorf("list dirty active channels for user: %w", err) @@ -381,6 +410,15 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, } else if ok { return ch, member, true, nil } + if ch.Monoforum && ch.LinkedMonoforumID != 0 { + parent, parentErr := s.channelByID(ctx, db, ch.LinkedMonoforumID) + if parentErr != nil { + return domain.Channel{}, domain.ChannelMember{}, false, parentErr + } + if parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == ch.ID { + return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil + } + } if !publicPreviewableChannel(ch) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } diff --git a/internal/store/postgres/channel_invite_members.go b/internal/store/postgres/channel_invite_members.go index 0ffb75b8..ee546aaf 100644 --- a/internal/store/postgres/channel_invite_members.go +++ b/internal/store/postgres/channel_invite_members.go @@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } if !canInviteToChannel(channel, inviter) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired } diff --git a/internal/store/postgres/channel_member_admin.go b/internal/store/postgres/channel_member_admin.go index de40446e..f3c2eaff 100644 --- a/internal/store/postgres/channel_member_admin.go +++ b/internal/store/postgres/channel_member_admin.go @@ -544,6 +544,40 @@ LIMIT $2`, userID, domain.MaxAdminedPublicChannels) return out, nil } +func (s *ChannelStore) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id +FROM user_channel_member_index i +JOIN channels c ON c.id=i.channel_id +WHERE i.user_id=$1 + AND i.status='active' + AND i.role IN ('creator','admin') + AND NOT i.deleted + AND NOT c.monoforum + AND c.linked_community_id=0 +ORDER BY i.channel_id DESC +LIMIT $2`, userID, domain.MaxCommunityPeers) + if err != nil { + return nil, fmt.Errorf("list community-linkable channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, domain.MaxCommunityPeers) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return listChannelsByIDsInOrder(ctx, s.db, ids) +} + // ListSendAsChannels lists the broadcast channels a user may post messages AS in groups // (channels.getSendAs candidates): channels where the user is the creator, or an admin holding // PostMessages rights. Mirrors ListStoryPostableChannels but is restricted to broadcast channels diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go index 1092e2fe..ff26a5bb 100644 --- a/internal/store/postgres/channel_member_helpers.go +++ b/internal/store/postgres/channel_member_helpers.go @@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan return member } +func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: mono.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + } +} + func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { return rights == domain.ChannelAdminRights{} } diff --git a/internal/store/postgres/channel_member_join.go b/internal/store/postgres/channel_member_join.go index 3676d0e6..a948248a 100644 --- a/internal/store/postgres/channel_member_join.go +++ b/internal/store/postgres/channel_member_join.go @@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID) if existingErr == nil { switch { diff --git a/internal/store/postgres/channel_message_helpers.go b/internal/store/postgres/channel_message_helpers.go index 93452fd1..74679124 100644 --- a/internal/store/postgres/channel_message_helpers.go +++ b/internal/store/postgres/channel_message_helpers.go @@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { var richMessageJSON string var savedPeerType string var savedPeerID int64 + var suggestedPostJSON string if err := row.Scan( &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, - &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, ); err != nil { return domain.ChannelMessage{}, err } msg.From.Type = domain.PeerType(fromType) msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } @@ -90,17 +92,19 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er var richMessageJSON string var savedPeerType string var savedPeerID int64 + var suggestedPostJSON string if err := row.Scan( &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, - &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, &count, ); err != nil { return domain.ChannelMessage{}, 0, err } msg.From.Type = domain.PeerType(fromType) msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index 99f8fd00..8b5a9d94 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6 base := "channel_id = $1 AND NOT deleted" extraChannels := []domain.Channel(nil) if channel.Monoforum { - base += " AND saved_peer_id = 0" + if isChannelAdmin(member) { + base += " AND saved_peer_id = 0" + } else { + baseArgs = append(baseArgs, viewerUserID) + base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs)) + } if channel.LinkedMonoforumID != 0 { if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil { extraChannels = append(extraChannels, parent) @@ -216,8 +221,12 @@ func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID in if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { limit = domain.MaxChannelGlobalSearchLimit } - args := []any{viewerUserID} + args := []any{viewerUserID, req.AllowPublicPreview} where := `NOT deleted` + if req.RestrictChannelIDs { + args = append(args, req.ChannelIDs) + where += fmt.Sprintf("\nAND channel_id = ANY($%d::bigint[])", len(args)) + } if query != "" { args = append(args, "%"+escapeLike(query)+"%") where += fmt.Sprintf(` @@ -237,15 +246,18 @@ AND EXISTS ( where += ` AND EXISTS ( SELECT 1 - FROM channels c - JOIN channel_members cm ON cm.channel_id = c.id - AND cm.user_id = $1 - AND cm.status = 'active' - AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) + FROM channels c + LEFT JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = $1 LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 - WHERE c.id = channel_messages.channel_id - AND NOT c.deleted - AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND ( + (cm.status = 'active' AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)) + OR ($2::boolean AND COALESCE(c.username,'') <> '' + AND COALESCE(cm.status,'') <> 'kicked' + AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)) + ) + AND (COALESCE(cm.status,'') <> 'active' OR cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` if req.BroadcastsOnly { where += ` AND c.broadcast AND NOT c.megagroup` diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index 615cb541..05de824e 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan( Message: replay, SenderUserID: first.SenderUserID, } - return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil + result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete} + if first.PaidMessageStars > 0 { + balance := domain.StarsBalance{UserID: first.SenderUserID} + if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID). + Scan(&balance.Balance, &balance.Granted); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err) + } + result.SenderStarsBalance = &balance + } + return result, nil } func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { @@ -578,6 +587,10 @@ func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg d if err != nil { return err } + suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}") + if err != nil { + return err + } sendSnapshot := []byte("{}") if msg.RandomID != 0 { sendSnapshot, err = store.EncodeChannelSendSnapshot(msg) @@ -613,12 +626,12 @@ INSERT INTO channel_messages ( channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, - fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`, + fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post, send_snapshot, request_fingerprint + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39::jsonb,$40::jsonb,$41::bytea)`, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, - forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil { + forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, msg.PaidMessageStars, suggestedPost, sendSnapshot, requestFingerprint); err != nil { return fmt.Errorf("insert channel message: %w", err) } // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 diff --git a/internal/store/postgres/channel_monoforum.go b/internal/store/postgres/channel_monoforum.go index 3e8fd0a9..ff728a92 100644 --- a/internal/store/postgres/channel_monoforum.go +++ b/internal/store/postgres/channel_monoforum.go @@ -12,14 +12,19 @@ import ( "telesrv/internal/store" ) +const paidMessageChannelCommissionPermille int64 = 850 + // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 -// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员) -// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。 +// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者 +// 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。 func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || - req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if req.AllowPaidStars < 0 { + return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount + } requestFingerprint, err := store.MonoforumSendFingerprint(req) if err != nil { return domain.SendChannelMessageResult{}, err @@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send if channel.Deleted || !channel.Monoforum { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + var monoDeleted, parentDeleted, directEnabled bool + var linkedMonoforumID, monoPrice, parentPrice int64 + if err := tx.QueryRow(ctx, ` +SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id, + m.send_paid_messages_stars, p.send_paid_messages_stars +FROM channels m +JOIN channels p ON p.id = m.linked_monoforum_id +WHERE m.id = $1 +FOR SHARE OF m, p`, channel.ID).Scan( + &monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice, + ); err != nil { + return domain.SendChannelMessageResult{}, err + } + if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID { + return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate + } + if monoPrice != parentPrice || monoPrice < 0 { + return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID) + } + channel.SendPaidMessagesStars = monoPrice + parent.SendPaidMessagesStars = parentPrice + parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID) + if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) { + return domain.SendChannelMessageResult{}, parentMemberErr + } + isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) + if req.SenderUserID != req.SavedPeer.ID && !isAdmin { + return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired + } + var senderBalance *domain.StarsBalance + paidMessageStars := int64(0) + if !isAdmin && channel.SendPaidMessagesStars > 0 { + if req.AllowPaidStars < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars} + } + balance := domain.StarsBalance{UserID: req.SenderUserID} + if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID). + Scan(&balance.Balance, &balance.Granted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err) + } + if balance.Balance < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + paidMessageStars = channel.SendPaidMessagesStars + if err := tx.QueryRow(ctx, ` +UPDATE stars_balances +SET balance = balance - $2, updated_at = now() +WHERE user_id = $1 +RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err) + } + if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage, + domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil { + return domain.SendChannelMessageResult{}, err + } + channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000 + if channelCredit > 0 { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_stars_balances(channel_id, balance) +VALUES($1, $2) +ON CONFLICT(channel_id) DO UPDATE +SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err) + } + } + senderBalance = &balance + } + if req.ReplyTo != nil { + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + var exists bool + if err := tx.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 FROM channel_messages + WHERE channel_id = $1 AND id = $2 AND NOT deleted + AND saved_peer_type = $3 AND saved_peer_id = $4 +)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil { + return domain.SendChannelMessageResult{}, err + } + if !exists { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + } + from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + if isAdmin { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID} + } msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err) @@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err) } msg := domain.ChannelMessage{ - ChannelID: req.MonoforumID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.SenderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - SavedPeer: req.SavedPeer, - Date: req.Date, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Pts: pts, + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: from, + SavedPeer: req.SavedPeer, + SuggestedPost: req.SuggestedPost, + PaidMessageStars: paidMessageStars, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: req.ReplyTo, + Pts: pts, } event := domain.ChannelUpdateEvent{ ChannelID: req.MonoforumID, @@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err) } + recipients := []int64{req.SavedPeer.ID} + rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err) + } + for rows.Next() { + var recipient int64 + if err := rows.Scan(&recipient); err != nil { + rows.Close() + return domain.SendChannelMessageResult{}, err + } + recipients = append(recipients, recipient) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.SendChannelMessageResult{}, err + } + rows.Close() if err := tx.Commit(ctx); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err) } committed = true channel.TopMessageID = msgID channel.Pts = pts - return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil } // ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。 @@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m return domain.Channel{}, false, domain.ErrChannelInvalid } isAdmin := false - if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil { + if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil { isAdmin = member.Status == domain.ChannelMemberActive && (member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin) + } else if !errors.Is(memberErr, domain.ErrChannelPrivate) { + return domain.Channel{}, false, memberErr } return mono, isAdmin, nil } diff --git a/internal/store/postgres/channel_monoforum_send_integration_test.go b/internal/store/postgres/channel_monoforum_send_integration_test.go index 64bc5c1b..a607e699 100644 --- a/internal/store/postgres/channel_monoforum_send_integration_test.go +++ b/internal/store/postgres/channel_monoforum_send_integration_test.go @@ -3,6 +3,7 @@ package postgres import ( "context" "errors" + "slices" "testing" "telesrv/internal/domain" @@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { channelIDs = append(channelIDs, monoID) subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + if _, err := channels.GetChannel(ctx, sub.ID, monoID); err != nil { + t.Fatalf("subscriber get enabled monoforum without membership: %v", err) + } + if _, err := channels.JoinChannel(ctx, monoID, sub.ID, 1700001001); !errors.Is(err, domain.ErrChannelMonoforumUnsupported) { + t.Fatalf("subscriber join monoforum err = %v, want ErrChannelMonoforumUnsupported", err) + } + suggestedDraft := domain.DialogDraft{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, Message: "pending suggested post", Date: 1700001001, + SuggestedPost: &domain.SuggestedPost{ + Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, + ScheduleDate: 1700100000, + }, + } + dialogStore := NewDialogStore(pool) + if err := dialogStore.SaveDraft(ctx, sub.ID, suggestedDraft); err != nil { + t.Fatalf("save subscriber monoforum draft: %v", err) + } + loadedDraft, found, err := dialogStore.GetDraft(ctx, sub.ID, suggestedDraft.Peer, 0) + if err != nil || !found || loadedDraft.SuggestedPost == nil || loadedDraft.SuggestedPost.Price == nil || loadedDraft.SuggestedPost.Price.Amount != 10 || loadedDraft.SuggestedPost.ScheduleDate != 1700100000 { + t.Fatalf("loaded subscriber monoforum draft = %+v, %v, %v; want suggested post", loadedDraft, found, err) + } - m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001}) + suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000} + m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001, + SuggestedPost: suggestedPost, + }) if err != nil { t.Fatalf("subscriber send 1: %v", err) } if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) } + if len(m1.Recipients) != 2 || !slices.Contains(m1.Recipients, owner.ID) || !slices.Contains(m1.Recipients, sub.ID) { + t.Fatalf("m1 recipients = %v, want subscriber %d + parent admin %d", m1.Recipients, sub.ID, owner.ID) + } if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil { t.Fatalf("subscriber send 2: %v", err) } - if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil { + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001003}); err != nil { t.Fatalf("admin reply: %v", err) } @@ -85,12 +114,21 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) } - if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { - t.Fatalf("subscriber main monoforum history = nil err, want denied") + subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber monoforum history: %v", err) + } + if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 { + t.Fatalf("subscriber monoforum history count=%d len=%d, want own 3", subscriberHist.Count, len(subscriberHist.Messages)) + } + for _, message := range subscriberHist.Messages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber history leaked message %+v", message) + } } // 幂等。 - dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004}) + dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004}) if err != nil { t.Fatalf("dup send: %v", err) } @@ -124,6 +162,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) } } + oldest := hist.Messages[len(hist.Messages)-1] + if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 { + t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost) + } + if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID { + t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID) + } + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 114, Message: "bad reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err) + } // 另一个订阅者不串会话。 otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} @@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if subHist.Count != 3 { t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) } + subscriberChannelHistory, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil || subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 { + t.Fatalf("subscriber channel history after other = %d/%d, %v; want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages), err) + } + for _, message := range subscriberChannelHistory.Messages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber channel history leaked message %+v", message) + } + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("subscriber channel difference: %v", err) + } + if len(diff.NewMessages) != 3 { + t.Fatalf("subscriber channel difference messages = %d, want own 3", len(diff.NewMessages)) + } + for _, message := range diff.NewMessages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber difference leaked message %+v", message) + } + } + activeChannelIDs, err := channels.ListActiveChannelIDsForUser(ctx, sub.ID, 0, 10) + if err != nil || !slices.Contains(activeChannelIDs, monoID) { + t.Fatalf("subscriber active channels = %v, %v; want monoforum %d", activeChannelIDs, err, monoID) + } // 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同 // 订阅者发,不得互相去重(与 memory 行为一致)。 @@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if err := tx.Commit(ctx); err != nil { t.Fatalf("commit monoforum delete: %v", err) } + deleteDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10}) + if err != nil { + t.Fatalf("subscriber difference after own delete: %v", err) + } + if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID { + t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts) + } var ptsBeforeReplay, eventsBeforeReplay int if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil { t.Fatalf("load monoforum pts: %v", err) @@ -227,3 +307,117 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay) } } + +func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + channels := NewChannelStore(pool) + broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true) + if err != nil { + t.Fatalf("enable paid DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID}) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID}) + }) + stars := NewStarsStore(pool) + if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil { + t.Fatalf("grant subscriber stars: %v", err) + } + if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil { + t.Fatalf("grant other stars: %v", err) + } + subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + var beforeMessages int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil { + t.Fatalf("count messages before paid send: %v", err) + } + lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001} + var required *domain.StarsPaymentRequiredError + if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 { + t.Fatalf("low authorization err = %v, want 10-Star payment required", err) + } + var afterLowMessages int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages { + t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages) + } + + paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002} + paid, err := channels.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid send: %v", err) + } + if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance) + } + var senderBalance, channelBalance, persistedPaid int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil { + t.Fatalf("load sender balance: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil { + t.Fatalf("load channel balance: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil { + t.Fatalf("load persisted paid stars: %v", err) + } + if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 { + t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid) + } + + replay, err := channels.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid replay: %v", err) + } + if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid replay = %+v, want exact original and balance 15", replay) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 { + t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 { + t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err) + } + + admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003, + }) + if err != nil { + t.Fatalf("admin reply: %v", err) + } + if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil { + t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance) + } + + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004, + }); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err) + } + var otherBalance int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 { + t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 { + t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err) + } +} diff --git a/internal/store/postgres/channel_store.go b/internal/store/postgres/channel_store.go index c21e2bfa..9be8d7fd 100644 --- a/internal/store/postgres/channel_store.go +++ b/internal/store/postgres/channel_store.go @@ -116,7 +116,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link, -c.linked_chat_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text, +c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text, c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, c.wallpaper::text, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped, @@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text, -reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` +reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post::text` const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, diff --git a/internal/store/postgres/channel_updates.go b/internal/store/postgres/channel_updates.go index 331768fe..bf03c14c 100644 --- a/internal/store/postgres/channel_updates.go +++ b/internal/store/postgres/channel_updates.go @@ -48,6 +48,10 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } + if channel.Monoforum && !isChannelAdmin(member) { + args = append(args, req.UserID) + where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args)) + } args = append(args, domain.MaxChannelDifferenceTooLongMessages) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` @@ -100,11 +104,15 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) if err != nil { return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) } - defer rows.Close() diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30} userRefs := make(map[int64]struct{}) channelRefs := make(map[int64]struct{}) lastPts := req.Pts + type differenceEventRow struct { + event domain.ChannelUpdateEvent + messageID int + } + eventRows := make([]differenceEventRow, 0, limit) for rows.Next() { event, messageID, err := scanChannelEvent(rows) if err != nil { @@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) break } lastPts = event.Pts + eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID}) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ChannelDifference{}, err + } + rows.Close() + var visibleMonoforumMessageIDs map[int]struct{} + if channel.Monoforum && !isChannelAdmin(member) { + messageIDs := make([]int, 0) + for _, row := range eventRows { + messageIDs = append(messageIDs, row.event.MessageIDs...) + } + visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs) + if err != nil { + return domain.ChannelDifference{}, err + } + } + for _, row := range eventRows { + event := row.event + messageID := row.messageID if messageID != 0 && event.Message.ID == 0 { msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) if err != nil { @@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) continue } event = visibleEvent + if channel.Monoforum && !isChannelAdmin(member) { + event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs) + if !ok { + continue + } + } if preview && event.Type == domain.ChannelUpdateParticipant { continue } @@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) diff.OtherUpdates = append(diff.OtherUpdates, event) } } - if err := rows.Err(); err != nil { - return domain.ChannelDifference{}, err - } if len(diff.Events) == 0 { diff.Pts = lastPts } else if lastPts > diff.Pts { @@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) return diff, nil } +func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) { + visible := make(map[int]struct{}) + if len(ids) == 0 { + return visible, nil + } + rows, err := s.db.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 + AND id = ANY($2::int[]) + AND saved_peer_type = 'user' + AND saved_peer_id = $3`, channelID, int32s(ids), userID) + if err != nil { + return nil, fmt.Errorf("list visible monoforum message ids: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + visible[id] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, err + } + return visible, nil +} + +func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) { + if event.Message.ID != 0 { + return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } + if len(event.MessageIDs) == 0 { + return event, false + } + ids := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if _, ok := visibleMessageIDs[id]; ok { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return event, false + } + event.MessageIDs = ids + return event, true +} + func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { var pts int err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts) diff --git a/internal/store/postgres/community.go b/internal/store/postgres/community.go new file mode 100644 index 00000000..b37e5b2c --- /dev/null +++ b/internal/store/postgres/community.go @@ -0,0 +1,1374 @@ +package postgres + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +type CommunityStore struct { + db sqlcgen.DBTX + ids store.ChannelIDAllocator + msgIDs store.ChannelMessageIDAllocator +} + +func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator) *CommunityStore { + if ids == nil { + ids = pgChannelIDAllocator{db: db} + } + if msgIDs == nil { + msgIDs = pgChannelMessageIDAllocator{db: db} + } + return &CommunityStore{db: db, ids: ids, msgIDs: msgIDs} +} + +func (s *CommunityStore) appendCommunityServiceMessageTx(ctx context.Context, tx pgx.Tx, peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) { + if peer.Type != domain.PeerTypeChannel { + return nil, nil + } + channel, err := getChannelByID(ctx, tx, peer.ID) + if err != nil { + return nil, err + } + channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs)) + message, event, err := channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChangeCommunity, + CommunityID: communityID, + }) + if err != nil { + return nil, err + } + channel.TopMessageID = message.ID + channel.Pts = event.Pts + // The Community transaction has already validated the actor and holds the + // linked channel row. Use the internal membership scan here: the public + // method treats viewerUserID=0 as an unauthenticated private-channel read. + recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channel.ID, 0) + if err != nil { + return nil, err + } + return &domain.SendChannelMessageResult{Channel: channel, Message: message, Event: event, Recipients: recipients}, nil +} + +const communityColumns = `id, access_hash, creator_user_id, title, about, +default_banned_rights::text, photo_id, photo_dc_id, photo_stripped, date, deleted` + +func scanCommunity(row rowScanner) (domain.Community, error) { + var c domain.Community + var rights string + if err := row.Scan(&c.ID, &c.AccessHash, &c.CreatorUserID, &c.Title, &c.About, + &rights, &c.PhotoID, &c.PhotoDCID, &c.PhotoStripped, &c.Date, &c.Deleted); err != nil { + return domain.Community{}, err + } + if err := json.Unmarshal([]byte(rights), &c.DefaultBannedRights); err != nil { + return domain.Community{}, fmt.Errorf("decode community banned rights: %w", err) + } + c.PhotoStripped = append([]byte(nil), c.PhotoStripped...) + return c, nil +} + +func scanCommunityMember(row rowScanner) (domain.CommunityMember, error) { + var m domain.CommunityMember + var role, status, rights string + if err := row.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date); err != nil { + return domain.CommunityMember{}, err + } + m.Role = domain.CommunityMemberRole(role) + m.Status = domain.CommunityMemberStatus(status) + if err := json.Unmarshal([]byte(rights), &m.AdminRights); err != nil { + return domain.CommunityMember{}, fmt.Errorf("decode community admin rights: %w", err) + } + return m, nil +} + +func (s *CommunityStore) begin(ctx context.Context) (pgx.Tx, error) { + b, ok := s.db.(txBeginner) + if !ok { + return nil, errors.New("community store requires transaction-capable db") + } + return b.Begin(ctx) +} + +func withCommunityTx[T any](ctx context.Context, s *CommunityStore, fn func(pgx.Tx) (T, error)) (T, error) { + var zero T + tx, err := s.begin(ctx) + if err != nil { + return zero, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + out, err := fn(tx) + if err != nil { + return zero, err + } + if err := tx.Commit(ctx); err != nil { + return zero, fmt.Errorf("commit community transaction: %w", err) + } + committed = true + return out, nil +} + +func communityByID(ctx context.Context, db sqlcgen.DBTX, id int64, forUpdate bool) (domain.Community, error) { + lock := "" + if forUpdate { + lock = " FOR UPDATE" + } + c, err := scanCommunity(db.QueryRow(ctx, `SELECT `+communityColumns+` FROM communities WHERE id=$1 AND NOT deleted`+lock, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Community{}, domain.ErrCommunityInvalid + } + return domain.Community{}, fmt.Errorf("get community: %w", err) + } + return c, nil +} + +func explicitCommunityMember(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityMember, bool, error) { + m, err := scanCommunityMember(db.QueryRow(ctx, ` +SELECT community_id, user_id, role, status, admin_rights::text, rank, date +FROM community_members WHERE community_id=$1 AND user_id=$2`, communityID, userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityMember{}, false, nil + } + return domain.CommunityMember{}, false, fmt.Errorf("get community member: %w", err) + } + return m, true, nil +} + +func derivedCommunityMember(ctx context.Context, db sqlcgen.DBTX, c domain.Community, userID int64) (domain.CommunityMember, bool, error) { + if userID == 0 { + return domain.CommunityMember{}, false, nil + } + if m, ok, err := explicitCommunityMember(ctx, db, c.ID, userID); err != nil || ok { + return m, ok, err + } + var joined bool + err := db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM community_peer_links l + JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=$1 AND cm.user_id=$2 AND cm.status='active' + UNION ALL + SELECT 1 + FROM community_peer_links l + JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=$1 AND d.user_id=$2 AND d.top_message_id > 0 + LIMIT 1 +)`, c.ID, userID).Scan(&joined) + if err != nil { + return domain.CommunityMember{}, false, fmt.Errorf("derive community member: %w", err) + } + if !joined { + return domain.CommunityMember{}, false, nil + } + return domain.CommunityMember{ + CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, + Status: domain.CommunityMemberActive, Date: c.Date, + }, true, nil +} + +func communityState(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityUserState, error) { + state := domain.CommunityUserState{CommunityID: communityID, UserID: userID} + err := db.QueryRow(ctx, ` +SELECT collapsed, pinned, pinned_order FROM community_user_states +WHERE community_id=$1 AND user_id=$2`, communityID, userID). + Scan(&state.Collapsed, &state.Pinned, &state.PinnedOrder) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityUserState{}, fmt.Errorf("get community state: %w", err) + } + return state, nil +} + +func communityLinkRows(ctx context.Context, db sqlcgen.DBTX, c domain.Community, viewer domain.CommunityMember) ([]domain.CommunityPeerLink, error) { + rows, err := db.Query(ctx, ` +SELECT l.peer_type, l.peer_id, l.visibility, l.created_by, l.date, + CASE + WHEN l.peer_type='channel' THEN EXISTS ( + SELECT 1 FROM channel_members cm + WHERE cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active') + ELSE EXISTS ( + SELECT 1 FROM dialogs d + WHERE d.user_id=$2 AND d.peer_type='user' AND d.peer_id=l.peer_id AND d.top_message_id > 0) + END AS joined, + CASE + WHEN l.peer_type='user' THEN true + ELSE EXISTS ( + SELECT 1 FROM channels c + WHERE c.id=l.peer_id AND NOT c.deleted AND COALESCE(c.username,'') <> '') + END AS inherently_viewable +FROM community_peer_links l +WHERE l.community_id=$1 +ORDER BY l.date, l.peer_type, l.peer_id`, c.ID, viewer.UserID) + if err != nil { + return nil, fmt.Errorf("list community links: %w", err) + } + defer rows.Close() + canManage := viewer.CanManageLinkedPeers() + out := make([]domain.CommunityPeerLink, 0) + for rows.Next() { + var typ, visibility string + var id, createdBy int64 + var date int + var joined, inherentlyViewable bool + if err := rows.Scan(&typ, &id, &visibility, &createdBy, &date, &joined, &inherentlyViewable); err != nil { + return nil, fmt.Errorf("scan community link: %w", err) + } + if visibility == string(domain.CommunityPeerHidden) && !joined && !canManage { + continue + } + out = append(out, domain.CommunityPeerLink{ + CommunityID: c.ID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: id}, + Visibility: domain.CommunityPeerVisibility(visibility), CanViewHistory: joined || inherentlyViewable, + CreatedBy: createdBy, Date: date, + }) + } + return out, rows.Err() +} + +func communityView(ctx context.Context, db sqlcgen.DBTX, viewerUserID, communityID int64) (domain.CommunityView, error) { + c, err := communityByID(ctx, db, communityID, false) + if err != nil { + return domain.CommunityView{}, err + } + self, joined, err := derivedCommunityMember(ctx, db, c, viewerUserID) + if err != nil { + return domain.CommunityView{}, err + } + if !joined || !self.Active() { + return domain.CommunityView{Community: c, Self: self, Forbidden: true}, domain.ErrCommunityPrivate + } + state, err := communityState(ctx, db, c.ID, viewerUserID) + if err != nil { + return domain.CommunityView{}, err + } + links, err := communityLinkRows(ctx, db, c, self) + if err != nil { + return domain.CommunityView{}, err + } + view := domain.CommunityView{Community: c, Self: self, State: state, Links: links} + channelIDs, userIDs := make([]int64, 0, len(links)), make([]int64, 0, len(links)) + for _, link := range links { + if link.Peer.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, link.Peer.ID) + } else { + userIDs = append(userIDs, link.Peer.ID) + } + } + view.Channels, err = listChannelsByIDs(ctx, db, channelIDs) + if err != nil { + return domain.CommunityView{}, err + } + view.Users, err = listUsersByIDs(ctx, db, userIDs) + if err != nil { + return domain.CommunityView{}, err + } + err = db.QueryRow(ctx, ` +SELECT + COUNT(*) FILTER (WHERE status='active' AND role IN ('creator','admin'))::int, + COUNT(*) FILTER (WHERE status='kicked')::int, + (SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1) +FROM community_members WHERE community_id=$1`, c.ID). + Scan(&view.AdminsCount, &view.KickedCount, &view.PendingRequests) + if err != nil { + return domain.CommunityView{}, fmt.Errorf("get community counts: %w", err) + } + return view, nil +} + +func (s *CommunityStore) GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) { + return communityView(ctx, s.db, viewerUserID, communityID) +} + +func (s *CommunityStore) GetCommunities(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.CommunityView, error) { + seen := make(map[int64]struct{}, len(ids)) + out := make([]domain.CommunityView, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + view, err := communityView(ctx, s.db, viewerUserID, id) + if errors.Is(err, domain.ErrCommunityInvalid) || errors.Is(err, domain.ErrCommunityPrivate) { + continue + } + if err != nil { + return nil, err + } + out = append(out, view) + } + return out, nil +} + +func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) { + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT c.id +FROM communities c +LEFT JOIN community_members explicit ON explicit.community_id=c.id AND explicit.user_id=$1 +WHERE NOT c.deleted AND ( + (explicit.status='active') + OR (explicit.user_id IS NULL AND EXISTS ( + SELECT 1 FROM community_peer_links l + JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=c.id AND cm.user_id=$1 AND cm.status='active')) + OR (explicit.user_id IS NULL AND EXISTS ( + SELECT 1 FROM community_peer_links l + JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=c.id AND d.user_id=$1 AND d.top_message_id > 0)) +) +ORDER BY c.id`, viewerUserID) + if err != nil { + return nil, fmt.Errorf("list joined communities: %w", err) + } + defer rows.Close() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return s.GetCommunities(ctx, viewerUserID, ids) +} + +func validateCommunityPeerForLink(ctx context.Context, db sqlcgen.DBTX, actorUserID int64, peer domain.Peer, lock bool) error { + lockSQL := "" + if lock { + lockSQL = " FOR UPDATE" + } + switch peer.Type { + case domain.PeerTypeChannel: + var linked int64 + var deleted, monoforum bool + if err := db.QueryRow(ctx, `SELECT linked_community_id, deleted, monoforum FROM channels WHERE id=$1`+lockSQL, peer.ID). + Scan(&linked, &deleted, &monoforum); err != nil { + return domain.ErrCommunityPeerInvalid + } + if deleted || monoforum { + return domain.ErrCommunityPeerInvalid + } + if linked != 0 { + return domain.ErrCommunityPeerLinked + } + var allowed bool + if err := db.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2 AND status='active' AND role IN ('creator','admin'))`, peer.ID, actorUserID).Scan(&allowed); err != nil || !allowed { + return domain.ErrCommunityAdminRequired + } + case domain.PeerTypeUser: + var bot, deleted bool + var linked int64 + if err := db.QueryRow(ctx, `SELECT is_bot, deleted_at IS NOT NULL, linked_community_id FROM users WHERE id=$1`+lockSQL, peer.ID). + Scan(&bot, &deleted, &linked); err != nil || !bot || deleted { + return domain.ErrCommunityPeerInvalid + } + if linked != 0 { + return domain.ErrCommunityPeerLinked + } + var owned bool + if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM bots WHERE bot_user_id=$1 AND owner_user_id=$2)`, peer.ID, actorUserID).Scan(&owned); err != nil || !owned { + return domain.ErrCommunityAdminRequired + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func setPeerLinkedCommunity(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, communityID int64) error { + var tag string + switch peer.Type { + case domain.PeerTypeChannel: + tag = "UPDATE channels SET linked_community_id=$2, updated_at=now() WHERE id=$1" + case domain.PeerTypeUser: + tag = "UPDATE users SET linked_community_id=$2, updated_at=now() WHERE id=$1" + default: + return domain.ErrCommunityPeerInvalid + } + cmd, err := db.Exec(ctx, tag, peer.ID, communityID) + if err != nil { + return fmt.Errorf("set linked community: %w", err) + } + if cmd.RowsAffected() != 1 { + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func insertCommunityLink(ctx context.Context, db sqlcgen.DBTX, communityID, actorUserID int64, peer domain.Peer, visibility domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) { + if err := validateCommunityPeerForLink(ctx, db, actorUserID, peer, true); err != nil { + return domain.CommunityPeerLink{}, err + } + var peers, bots int + if err := db.QueryRow(ctx, `SELECT +COUNT(*) FILTER (WHERE peer_type='channel')::int, +COUNT(*) FILTER (WHERE peer_type='user')::int +FROM community_peer_links WHERE community_id=$1`, communityID).Scan(&peers, &bots); err != nil { + return domain.CommunityPeerLink{}, err + } + if (peer.Type == domain.PeerTypeChannel && peers >= domain.MaxCommunityPeers) || + (peer.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch + } + if _, err := db.Exec(ctx, ` +INSERT INTO community_peer_links(community_id,peer_type,peer_id,visibility,created_by,date) +VALUES($1,$2,$3,$4,$5,$6)`, communityID, string(peer.Type), peer.ID, string(visibility), actorUserID, date); err != nil { + if isUniqueViolation(err) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeerLinked + } + return domain.CommunityPeerLink{}, fmt.Errorf("insert community link: %w", err) + } + if err := setPeerLinkedCommunity(ctx, db, peer, communityID); err != nil { + return domain.CommunityPeerLink{}, err + } + return domain.CommunityPeerLink{CommunityID: communityID, Peer: peer, Visibility: visibility, CanViewHistory: true, CreatedBy: actorUserID, Date: date}, nil +} + +func (s *CommunityStore) CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityView, error) { + if err := validateCommunityPeerForLink(ctx, tx, req.CreatorUserID, req.InitialPeer, true); err != nil { + return domain.CommunityView{}, err + } + id, err := s.ids.NextChannelID(ctx) + if err != nil { + return domain.CommunityView{}, fmt.Errorf("allocate community id: %w", err) + } + hash, err := randomChannelAccessHash() + if err != nil { + return domain.CommunityView{}, fmt.Errorf("community access hash: %w", err) + } + c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date} + rights, _ := json.Marshal(c.DefaultBannedRights) + if _, err := tx.Exec(ctx, ` +INSERT INTO communities(id,access_hash,creator_user_id,title,about,default_banned_rights,date) +VALUES($1,$2,$3,$4,$5,$6,$7)`, c.ID, c.AccessHash, c.CreatorUserID, c.Title, c.About, rights, c.Date); err != nil { + return domain.CommunityView{}, fmt.Errorf("insert community: %w", err) + } + adminRights, _ := json.Marshal(domain.CreatorChannelAdminRights()) + if _, err := tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date) +VALUES($1,$2,'creator','active',$3,$4)`, c.ID, c.CreatorUserID, adminRights, c.Date); err != nil { + return domain.CommunityView{}, fmt.Errorf("insert community creator: %w", err) + } + link, err := insertCommunityLink(ctx, tx, c.ID, c.CreatorUserID, req.InitialPeer, req.Visibility, req.Date) + if err != nil { + return domain.CommunityView{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.InitialPeer, req.CreatorUserID, req.Date, c.ID) + if err != nil { + return domain.CommunityView{}, err + } + self := domain.CommunityMember{CommunityID: c.ID, UserID: c.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: c.Date} + view := domain.CommunityView{Community: c, Self: self, Links: []domain.CommunityPeerLink{link}, AdminsCount: 1} + if serviceMessage != nil { + view.ServiceMessages = append(view.ServiceMessages, *serviceMessage) + } + return view, nil + }) +} + +func lockCommunityActor(ctx context.Context, tx pgx.Tx, actorUserID, communityID int64) (domain.Community, domain.CommunityMember, error) { + c, err := communityByID(ctx, tx, communityID, true) + if err != nil { + return domain.Community{}, domain.CommunityMember{}, err + } + m, ok, err := derivedCommunityMember(ctx, tx, c, actorUserID) + if err != nil { + return domain.Community{}, domain.CommunityMember{}, err + } + if !ok || !m.Active() { + return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate + } + return c, m, nil +} + +func (s *CommunityStore) ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if req.Deleted { + if !actor.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + cmd, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, c.ID, string(req.Peer.Type), req.Peer.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if cmd.RowsAffected() == 0 { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + if err := setPeerLinkedCommunity(ctx, tx, req.Peer, 0); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, 0) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil + } + if actor.CanManageLinkedPeers() { + link, err := insertCommunityLink(ctx, tx, c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, c.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &link, ServiceMessage: serviceMessage}, nil + } + if c.DefaultBannedRights.ManageLinkedPeers { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if err := validateCommunityPeerForLink(ctx, tx, req.ActorUserID, req.Peer, true); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO community_peer_link_requests(community_id,peer_type,peer_id,requested_by,visibility,date) +VALUES($1,$2,$3,$4,$5,$6) +ON CONFLICT(community_id,peer_type,peer_id) DO UPDATE SET requested_by=EXCLUDED.requested_by, visibility=EXCLUDED.visibility, date=EXCLUDED.date, created_at=now()`, + c.ID, string(req.Peer.Type), req.Peer.ID, req.ActorUserID, string(req.Visibility), req.Date); err != nil { + return domain.CommunityTogglePeerLinkResult{}, fmt.Errorf("save community link request: %w", err) + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil + }) +} + +func (s *CommunityStore) SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + if _, _, err := lockCommunityActor(ctx, tx, userID, communityID); err != nil { + return false, err + } + var old bool + err := tx.QueryRow(ctx, `SELECT collapsed FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&old) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return false, err + } + if err == nil && old == collapsed { + return false, nil + } + _, err = tx.Exec(ctx, ` +INSERT INTO community_user_states(community_id,user_id,collapsed,pinned,pinned_order) +VALUES($1,$2,$3,false,0) +ON CONFLICT(community_id,user_id) DO UPDATE SET collapsed=EXCLUDED.collapsed, + pinned=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned ELSE false END, + pinned_order=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned_order ELSE 0 END, + updated_at=now()`, communityID, userID, collapsed) + return true, err + }) + if err != nil { + return domain.CommunityView{}, false, err + } + view, err := s.GetCommunity(ctx, userID, communityID) + return view, changed, err +} + +type communityRequestCursor struct { + Date int `json:"d"` + Type string `json:"t"` + ID int64 `json:"i"` +} + +func decodeCommunityRequestCursor(raw string) (communityRequestCursor, error) { + if strings.TrimSpace(raw) == "" { + return communityRequestCursor{}, nil + } + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return communityRequestCursor{}, domain.ErrCommunityInvalid + } + var c communityRequestCursor + if json.Unmarshal(b, &c) != nil || c.Date <= 0 || c.ID <= 0 { + return communityRequestCursor{}, domain.ErrCommunityInvalid + } + return c, nil +} + +func encodeCommunityRequestCursor(c communityRequestCursor) string { + b, _ := json.Marshal(c) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *CommunityStore) ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + if !view.Self.CanManageLinkedPeers() { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired + } + cursor, err := decodeCommunityRequestCursor(offset) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + if limit <= 0 || limit > domain.MaxCommunityLinkRequests { + limit = domain.MaxCommunityLinkRequests + } + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1`, communityID).Scan(&total); err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT peer_type,peer_id,requested_by,visibility,date +FROM community_peer_link_requests +WHERE community_id=$1 AND ($2::int=0 OR (date,peer_type,peer_id) < ($2,$3,$4)) +ORDER BY date DESC,peer_type DESC,peer_id DESC LIMIT $5`, communityID, cursor.Date, cursor.Type, cursor.ID, limit+1) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + defer rows.Close() + page := domain.CommunityPeerLinkRequestPage{TotalCount: total} + for rows.Next() { + var typ, visibility string + var peerID, requestedBy int64 + var date int + if err := rows.Scan(&typ, &peerID, &requestedBy, &visibility, &date); err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + page.Requests = append(page.Requests, domain.CommunityPeerLinkRequest{CommunityID: communityID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: peerID}, RequestedBy: requestedBy, Visibility: domain.CommunityPeerVisibility(visibility), Date: date}) + } + if len(page.Requests) > limit { + last := page.Requests[limit-1] + page.NextOffset = encodeCommunityRequestCursor(communityRequestCursor{Date: last.Date, Type: string(last.Peer.Type), ID: last.Peer.ID}) + page.Requests = page.Requests[:limit] + } + channelIDs, userIDs := make([]int64, 0), make([]int64, 0) + for _, req := range page.Requests { + userIDs = append(userIDs, req.RequestedBy) + if req.Peer.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, req.Peer.ID) + } else { + userIDs = append(userIDs, req.Peer.ID) + } + } + page.Channels, err = listChannelsByIDs(ctx, s.db, channelIDs) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + page.Users, err = listUsersByIDs(ctx, s.db, uniqueInt64s(userIDs)) + return page, err +} + +func requestForUpdate(ctx context.Context, tx pgx.Tx, communityID int64, peer domain.Peer) (domain.CommunityPeerLinkRequest, error) { + var typ, visibility string + var out domain.CommunityPeerLinkRequest + err := tx.QueryRow(ctx, ` +SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests +WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3 FOR UPDATE`, communityID, string(peer.Type), peer.ID). + Scan(&typ, &out.Peer.ID, &out.RequestedBy, &visibility, &out.Date) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityPeerLinkRequest{}, domain.ErrCommunityRequestMissing + } + return domain.CommunityPeerLinkRequest{}, err + } + out.CommunityID, out.Peer.Type, out.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility) + return out, nil +} + +func (s *CommunityStore) DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if !actor.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + req, err := requestForUpdate(ctx, tx, communityID, peer) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(peer.Type), peer.ID); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if reject { + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy}, nil + } + link, err := insertCommunityLink(ctx, tx, communityID, req.RequestedBy, peer, req.Visibility, date) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, peer, actorUserID, date, c.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy, Link: &link, ServiceMessage: serviceMessage}, nil + }) +} + +func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) ([]domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return nil, err + } + if !actor.CanManageLinkedPeers() { + return nil, domain.ErrCommunityAdminRequired + } + rows, err := tx.Query(ctx, `SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests WHERE community_id=$1 ORDER BY date,peer_type,peer_id FOR UPDATE`, communityID) + if err != nil { + return nil, err + } + requests := make([]domain.CommunityPeerLinkRequest, 0) + for rows.Next() { + var typ, visibility string + var r domain.CommunityPeerLinkRequest + if err := rows.Scan(&typ, &r.Peer.ID, &r.RequestedBy, &visibility, &r.Date); err != nil { + rows.Close() + return nil, err + } + r.CommunityID, r.Peer.Type, r.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility) + requests = append(requests, r) + } + rows.Close() + if reject { + _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID) + return make([]domain.CommunityTogglePeerLinkResult, len(requests)), err + } + var currentChannels, currentBots int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FILTER(WHERE peer_type='channel')::int, COUNT(*) FILTER(WHERE peer_type='user')::int FROM community_peer_links WHERE community_id=$1`, communityID).Scan(¤tChannels, ¤tBots); err != nil { + return nil, err + } + for _, r := range requests { + if r.Peer.Type == domain.PeerTypeChannel { + currentChannels++ + } else { + currentBots++ + } + if currentChannels > domain.MaxCommunityPeers || currentBots > domain.MaxCommunityBotPeers { + return nil, domain.ErrCommunityPeersTooMuch + } + if err := validateCommunityPeerForLink(ctx, tx, r.RequestedBy, r.Peer, true); err != nil { + return nil, err + } + } + out := make([]domain.CommunityTogglePeerLinkResult, 0, len(requests)) + for _, r := range requests { + link, err := insertCommunityLink(ctx, tx, communityID, r.RequestedBy, r.Peer, r.Visibility, date) + if err != nil { + return nil, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, r.Peer, actorUserID, date, c.ID) + if err != nil { + return nil, err + } + out = append(out, domain.CommunityTogglePeerLinkResult{Community: c, Peer: r.Peer, RequestedBy: r.RequestedBy, Link: &link, ServiceMessage: serviceMessage}) + } + _, err = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID) + return out, err + }) +} + +func (s *CommunityStore) GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityParticipantJoinedChats{}, err + } + if !view.Self.CanBanUsers() && viewerUserID != participantUserID { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired + } + rows, err := s.db.Query(ctx, ` +SELECT l.peer_id, cm.role +FROM community_peer_links l +JOIN channel_members cm ON cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active' +WHERE l.community_id=$1 AND l.peer_type='channel' +ORDER BY l.peer_id`, communityID, participantUserID) + if err != nil { + return domain.CommunityParticipantJoinedChats{}, err + } + defer rows.Close() + out := domain.CommunityParticipantJoinedChats{} + ids := make([]int64, 0) + for rows.Next() { + var id int64 + var role string + if err := rows.Scan(&id, &role); err != nil { + return out, err + } + ids = append(ids, id) + out.JoinedChatIDs = append(out.JoinedChatIDs, id) + if role == string(domain.ChannelRoleCreator) { + out.CreatorChatIDs = append(out.CreatorChatIDs, id) + } + } + if err := rows.Err(); err != nil { + return out, err + } + out.Channels, err = listChannelsByIDs(ctx, s.db, ids) + if err != nil { + return out, err + } + out.Users, err = listUsersByIDs(ctx, s.db, []int64{participantUserID}) + return out, err +} + +func (s *CommunityStore) banCommunityParticipantFromChannelTx(ctx context.Context, tx pgx.Tx, actorUserID, channelID, participantUserID int64, date int) (domain.EditChannelBannedResult, bool, error) { + channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs)) + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + previous, err := channelStore.getChannelMember(ctx, tx, channelID, participantUserID) + if errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelBannedResult{}, false, nil + } + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + if previous.Status != domain.ChannelMemberActive { + return domain.EditChannelBannedResult{}, false, nil + } + member := previous + member.InviterUserID = actorUserID + member.Role = domain.ChannelRoleMember + member.Status = domain.ChannelMemberKicked + member.LeftAt = date + member.BannedRights = domain.ChannelBannedRights{ViewMessages: true, UntilDate: 0} + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + if err := channelStore.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, UserID: actorUserID, Date: date, Type: domain.ChannelAdminLogParticipantKick, + PrevParticipant: &previous, NewParticipant: &member, + }); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + event := transientChannelParticipantEvent(channel.ID, actorUserID, previous, member, date) + if err := clearChannelMentionsForUserTx(ctx, tx, channelID, participantUserID); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + var serviceMessage domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if channel.Megagroup { + serviceMessage, serviceEvent, err = channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, UserIDs: []int64{participantUserID}, + }) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + channel.TopMessageID, channel.Pts = serviceMessage.ID, serviceEvent.Pts + } + recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channelID, 0) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + recipients = append(recipients, participantUserID) + return domain.EditChannelBannedResult{ + Channel: channel, Previous: previous, Participant: member, Event: event, + Recipients: recipients, Date: date, Message: serviceMessage, ServiceEvent: serviceEvent, + }, true, nil +} + +func (s *CommunityStore) ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityParticipantBanResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if !actor.CanBanUsers() || participantUserID == c.CreatorUserID { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired + } + if unban { + cmd, err := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked'`, communityID, participantUserID) + return domain.CommunityParticipantBanResult{Changed: cmd.RowsAffected() > 0}, err + } + participant, found, err := derivedCommunityMember(ctx, tx, c, participantUserID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if !found || (participant.Status != domain.CommunityMemberActive && participant.Status != domain.CommunityMemberKicked) { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid + } + var alreadyKicked bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( +SELECT 1 FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked')`, communityID, participantUserID).Scan(&alreadyKicked); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + // Chats owned by the banned participant (and their bots) leave the + // Community; the participant is kicked from every remaining linked chat. + rows, err := tx.Query(ctx, ` +SELECT l.peer_type,l.peer_id +FROM community_peer_links l +LEFT JOIN channels c ON l.peer_type='channel' AND c.id=l.peer_id +LEFT JOIN bots b ON l.peer_type='user' AND b.bot_user_id=l.peer_id +WHERE l.community_id=$1 AND (c.creator_user_id=$2 OR b.owner_user_id=$2) +FOR UPDATE OF l`, communityID, participantUserID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + owned := make([]domain.Peer, 0) + for rows.Next() { + var typ string + var id int64 + if err := rows.Scan(&typ, &id); err != nil { + rows.Close() + return domain.CommunityParticipantBanResult{}, err + } + owned = append(owned, domain.Peer{Type: domain.PeerType(typ), ID: id}) + } + rows.Close() + result := domain.CommunityParticipantBanResult{} + for _, p := range owned { + if _, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(p.Type), p.ID); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if err := setPeerLinkedCommunity(ctx, tx, p, 0); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, p, actorUserID, date, 0) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage}) + } + channelRows, err := tx.Query(ctx, `SELECT peer_id FROM community_peer_links WHERE community_id=$1 AND peer_type='channel' ORDER BY peer_id FOR UPDATE`, communityID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + channelIDs := make([]int64, 0) + for channelRows.Next() { + var channelID int64 + if err := channelRows.Scan(&channelID); err != nil { + channelRows.Close() + return domain.CommunityParticipantBanResult{}, err + } + channelIDs = append(channelIDs, channelID) + } + channelRows.Close() + for _, channelID := range channelIDs { + ban, changed, err := s.banCommunityParticipantFromChannelTx(ctx, tx, actorUserID, channelID, participantUserID, date) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if changed { + result.ChannelBans = append(result.ChannelBans, ban) + } + } + if !alreadyKicked { + if _, err := tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date) +VALUES($1,$2,'member','kicked','{}',$3) +ON CONFLICT(community_id,user_id) DO UPDATE SET role='member',status='kicked',admin_rights='{}',rank='',date=EXCLUDED.date,updated_at=now()`, communityID, participantUserID, date); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + } + result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0 + return result, nil + }) +} + +func communityParticipantHash(items []domain.CommunityMember) int64 { + h := fnv.New64a() + for _, m := range items { + fmt.Fprintf(h, "%d:%s:%s;", m.UserID, m.Role, m.Status) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func (s *CommunityStore) ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityParticipantList{}, err + } + restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned + if restricted && !view.Self.CanManageLinkedPeers() { + return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired + } + where := "am.status='active'" + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + where = "am.status='active' AND am.role IN ('creator','admin')" + case domain.ChannelParticipantsKicked, domain.ChannelParticipantsBanned: + where = "am.status='kicked'" + } + query := ` +WITH derived AS ( + SELECT cm.user_id FROM community_peer_links l JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=$1 AND cm.status='active' + UNION + SELECT d.user_id FROM community_peer_links l JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=$1 AND d.top_message_id>0 +), all_members AS ( + SELECT community_id,user_id,role,status,admin_rights,rank,date FROM community_members WHERE community_id=$1 + UNION ALL + SELECT $1,d.user_id,'member','active','{}'::jsonb,'',0 FROM derived d + WHERE NOT EXISTS(SELECT 1 FROM community_members m WHERE m.community_id=$1 AND m.user_id=d.user_id) +) +SELECT am.community_id,am.user_id,am.role,am.status,am.admin_rights::text,am.rank,am.date,COUNT(*) OVER()::int +FROM all_members am +JOIN users u ON u.id=am.user_id +WHERE ` + where + ` + AND ($4='' OR strpos(lower(concat_ws(' ',am.user_id::text,u.first_name,u.last_name,u.username,u.phone)),lower($4))>0) +ORDER BY CASE am.role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,am.user_id OFFSET $2 LIMIT $3` + rows, err := s.db.Query(ctx, query, communityID, offset, limit, strings.TrimSpace(filter.Query)) + if err != nil { + return domain.CommunityParticipantList{}, err + } + defer rows.Close() + out := domain.CommunityParticipantList{Community: view.Community} + ids := make([]int64, 0) + for rows.Next() { + var m domain.CommunityMember + var role, status, rights string + var count int + if err := rows.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date, &count); err != nil { + return out, err + } + m.Role, m.Status = domain.CommunityMemberRole(role), domain.CommunityMemberStatus(status) + _ = json.Unmarshal([]byte(rights), &m.AdminRights) + out.Count = count + out.Participants = append(out.Participants, m) + ids = append(ids, m.UserID) + } + out.Users, err = listUsersByIDs(ctx, s.db, ids) + out.Hash = communityParticipantHash(out.Participants) + return out, err +} + +func (s *CommunityStore) EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.Title == title { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET title=$2,updated_at=now() WHERE id=$1`, communityID, title) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.About == about { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET about=$2,updated_at=now() WHERE id=$1`, communityID, about) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func zeroCommunityAdminRights(r domain.ChannelAdminRights) bool { + return r == (domain.ChannelAdminRights{}) +} + +func (s *CommunityStore) EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, actor, e := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID) + if e != nil { + return false, e + } + if req.UserID == req.ActorUserID && actor.Role == domain.CommunityRoleAdmin && zeroCommunityAdminRights(req.Rights) { + cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID) + return cmd.RowsAffected() > 0, e + } + if !actor.CanAddAdmins() { + return false, domain.ErrCommunityAdminRequired + } + if req.UserID == c.CreatorUserID { + return false, domain.ErrCommunityCreatorRequired + } + if zeroCommunityAdminRights(req.Rights) { + cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID) + return cmd.RowsAffected() > 0, e + } + var exists bool + if e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL)`, req.UserID).Scan(&exists); e != nil || !exists { + return false, domain.ErrCommunityParticipantInvalid + } + rights, _ := json.Marshal(req.Rights) + _, e = tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,rank,date) +VALUES($1,$2,'admin','active',$3,$4,$5) +ON CONFLICT(community_id,user_id) DO UPDATE SET role='admin',status='active',admin_rights=EXCLUDED.admin_rights,rank=EXCLUDED.rank,date=EXCLUDED.date,updated_at=now()`, req.CommunityID, req.UserID, rights, req.Rank, req.Date) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, req.ActorUserID, req.CommunityID) + if changed && req.UserID == req.ActorUserID && errors.Is(err, domain.ErrCommunityPrivate) { + c, loadErr := communityByID(ctx, s.db, req.CommunityID, false) + if loadErr != nil { + return domain.CommunityView{}, false, loadErr + } + return domain.CommunityView{Community: c, Forbidden: true}, true, nil + } + return v, changed, err +} + +func (s *CommunityStore) EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.DefaultBannedRights == rights { + return false, nil + } + raw, _ := json.Marshal(rights) + _, e = tx.Exec(ctx, `UPDATE communities SET default_banned_rights=$2,updated_at=now() WHERE id=$1`, communityID, raw) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + id, dc := int64(0), 0 + var stripped []byte + if photo != nil { + id, dc = photo.ID, photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if c.PhotoID == id && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET photo_id=$2,photo_dc_id=$3,photo_stripped=$4,updated_at=now() WHERE id=$1`, communityID, id, dc, stripped) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) { + type result struct { + view domain.CommunityView + peers []domain.Peer + } + r, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (result, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return result{}, e + } + if m.Role != domain.CommunityRoleCreator { + return result{}, domain.ErrCommunityCreatorRequired + } + links, e := communityLinkRows(ctx, tx, c, m) + if e != nil { + return result{}, e + } + peers := make([]domain.Peer, 0, len(links)) + serviceMessages := make([]domain.SendChannelMessageResult, 0, len(links)) + for _, l := range links { + peers = append(peers, l.Peer) + if e := setPeerLinkedCommunity(ctx, tx, l.Peer, 0); e != nil { + return result{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageTx(ctx, tx, l.Peer, actorUserID, date, 0) + if e != nil { + return result{}, e + } + if serviceMessage != nil { + serviceMessages = append(serviceMessages, *serviceMessage) + } + } + if _, e = tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `DELETE FROM community_user_states WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `UPDATE communities SET deleted=true,title='',about='',updated_at=now() WHERE id=$1`, communityID); e != nil { + return result{}, e + } + c.Deleted = true + c.Title = "" + c.About = "" + return result{view: domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers: peers}, nil + }) + return r.view, r.peers, err +} + +func (s *CommunityStore) SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + if _, _, e := lockCommunityActor(ctx, tx, userID, communityID); e != nil { + return false, e + } + var collapsed, old bool + var order int + e := tx.QueryRow(ctx, `SELECT collapsed,pinned,pinned_order FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&collapsed, &old, &order) + if e != nil { + return false, domain.ErrCommunityInvalid + } + if !collapsed { + return false, domain.ErrCommunityInvalid + } + if old == pinned { + return false, nil + } + if pinned { + if e := tx.QueryRow(ctx, `SELECT GREATEST(1000000000,COALESCE(MAX(pinned_order),0))+1 FROM community_user_states WHERE user_id=$1 AND pinned`, userID).Scan(&order); e != nil { + return false, e + } + } else { + order = 0 + } + _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=$3,pinned_order=$4,updated_at=now() WHERE community_id=$1 AND user_id=$2`, communityID, userID, pinned, order) + return true, e + }) +} + +func (s *CommunityStore) ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + seen := map[int64]struct{}{} + for _, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + if peer.ID == 0 { + return false, domain.ErrCommunityInvalid + } + if _, ok := seen[peer.ID]; ok { + return false, domain.ErrCommunityInvalid + } + seen[peer.ID] = struct{}{} + } + rows, e := tx.Query(ctx, `SELECT community_id,pinned_order FROM community_user_states WHERE user_id=$1 AND pinned FOR UPDATE`, userID) + if e != nil { + return false, e + } + old := map[int64]int{} + for rows.Next() { + var id int64 + var pinnedOrder int + if e := rows.Scan(&id, &pinnedOrder); e != nil { + rows.Close() + return false, e + } + old[id] = pinnedOrder + } + rows.Close() + changed := false + if force { + if _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=false,pinned_order=0,updated_at=now() WHERE user_id=$1 AND pinned`, userID); e != nil { + return false, e + } + changed = len(old) > 0 + } + for i, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + pinnedOrder := len(order) - i + if old[peer.ID] == pinnedOrder && !force { + continue + } + cmd, e := tx.Exec(ctx, `UPDATE community_user_states SET pinned=true,pinned_order=$3,updated_at=now() WHERE user_id=$1 AND community_id=$2 AND collapsed`, userID, peer.ID, pinnedOrder) + if e != nil { + return false, e + } + if cmd.RowsAffected() != 1 { + return false, domain.ErrCommunityInvalid + } + changed = true + } + return changed, nil + }) +} + +func (s *CommunityStore) CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) { + v, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunitySearchScope{}, err + } + out := domain.CommunitySearchScope{CommunityID: communityID} + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if l.CanViewHistory { + out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID) + } + } else { + out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID) + } + } + return out, nil +} + +func uniqueInt64s(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/internal/store/postgres/community_integration_test.go b/internal/store/postgres/community_integration_test.go new file mode 100644 index 00000000..e4f8d903 --- /dev/null +++ b/internal/store/postgres/community_integration_test.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 801, Phone: "+1888" + suffix + "01", FirstName: "CommunityOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 802, Phone: "+1888" + suffix + "02", FirstName: "SearchableMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channels := NewChannelStore(pool) + initial, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Community Initial " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1_800_200_000, + }) + if err != nil { + t.Fatalf("create initial channel: %v", err) + } + owned, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: member.ID, + Title: "Community Owned " + suffix, + Megagroup: true, + Date: 1_800_200_001, + }) + if err != nil { + t.Fatalf("create owned channel: %v", err) + } + owned.Channel, err = channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: member.ID, ChannelID: owned.Channel.ID, Username: "communitypreview" + suffix, + }) + if err != nil { + t.Fatalf("make owned channel public: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, ChannelID: owned.Channel.ID, RandomID: 9_900_000_001, + Message: "community public preview search", Date: 1_800_200_001, + }); err != nil { + t.Fatalf("send public preview message: %v", err) + } + var communityID int64 + t.Cleanup(func() { + if communityID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM communities WHERE id=$1", communityID) + } + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=ANY($1::bigint[])", []int64{initial.Channel.ID, owned.Channel.ID}) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + store := NewCommunityStore(pool, nil, nil) + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Postgres Community " + suffix, + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.Channel.ID}, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_200_002, + }) + if err != nil { + t.Fatalf("create community: %v", err) + } + communityID = created.Community.ID + if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Event.Pts == 0 { + t.Fatalf("create service messages = %+v", created.ServiceMessages) + } + var linkedID int64 + if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", initial.Channel.ID).Scan(&linkedID); err != nil || linkedID != communityID { + t.Fatalf("initial linked_community_id = %d err=%v, want %d", linkedID, err, communityID) + } + + requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: member.ID, + CommunityID: communityID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.Channel.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_200_003, + }) + if err != nil || !requested.RequestCreated { + t.Fatalf("create peer link request = %+v err=%v", requested, err) + } + approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, communityID, requested.Peer, false, 1_800_200_004) + if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil { + t.Fatalf("approve link request = %+v err=%v", approved, err) + } + search, err := channels.SearchJoinedMessages(ctx, owner.ID, domain.ChannelGlobalSearchRequest{ + Query: "public preview", ChannelIDs: []int64{owned.Channel.ID}, RestrictChannelIDs: true, + AllowPublicPreview: true, Limit: 20, + }) + if err != nil || len(search.Messages) != 1 || search.Messages[0].ChannelID != owned.Channel.ID { + t.Fatalf("community public-preview search = %+v err=%v", search.Messages, err) + } + + participants, err := store.ListCommunityParticipants(ctx, owner.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsSearch, Query: "SEARCHABLE", + }, 0, 20) + if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID { + t.Fatalf("participant search = %+v err=%v", participants, err) + } + admins, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100) + if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID { + t.Fatalf("member-visible Community admins = %+v err=%v", admins, err) + } + if _, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsBanned, + }, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) { + t.Fatalf("member Community banned list error = %v, want admin required", err) + } + + ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, communityID, member.ID, false, 1_800_200_005) + if err != nil { + t.Fatalf("ban participant: %v", err) + } + if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 { + t.Fatalf("ban result = %+v", ban) + } + if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", owned.Channel.ID).Scan(&linkedID); err != nil || linkedID != 0 { + t.Fatalf("owned linked_community_id after ban = %d err=%v, want 0", linkedID, err) + } + if _, err := store.GetCommunity(ctx, member.ID, communityID); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("banned member get community error = %v, want private", err) + } +} diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go index fb34a2a8..f8e1786a 100644 --- a/internal/store/postgres/contact.go +++ b/internal/store/postgres/contact.go @@ -87,6 +87,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -137,6 +139,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -278,6 +282,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed FROM upserted c @@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do premiumUntil int64 emojiStatusDocID int64 emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte lastSeenAt int64 reverseMutualChanged bool ) @@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, &reverseMutualChanged, ); err != nil { @@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do if err != nil { return nil, fmt.Errorf("decode contact note entities: %w", err) } - out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)) + out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate upsert contacts many: %w", err) @@ -556,7 +566,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { @@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) { @@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) { @@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } // contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote @@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, // raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username // 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须 // 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。 -func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact { +func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil int, emojiCollectibleID *int64, emojiCollectibleJSON []byte, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact { return domain.Contact{ User: domain.User{ - ID: id, - AccessHash: accessHash, - Phone: phone, - FirstName: firstName, - LastName: lastName, - Username: username, - CountryCode: countryCode, - Verified: verified, - Support: support, - Bot: isBot, - BotInfoVersion: botInfoVersion, - PremiumUntil: premiumUntil, - EmojiStatusDocumentID: emojiStatusDocumentID, - EmojiStatusUntil: emojiStatusUntil, - LastSeenAt: lastSeenAt, - Contact: true, - Mutual: mutual, - CloseFriend: closeFriend, + ID: id, + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + Username: username, + CountryCode: countryCode, + Verified: verified, + Support: support, + Bot: isBot, + BotInfoVersion: botInfoVersion, + PremiumUntil: premiumUntil, + EmojiStatusDocumentID: emojiStatusDocumentID, + EmojiStatusUntil: emojiStatusUntil, + EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON), + LastSeenAt: lastSeenAt, + Contact: true, + Mutual: mutual, + CloseFriend: closeFriend, }, FirstName: contactFirstName, LastName: contactLastName, @@ -626,27 +637,29 @@ type contactScanner interface { func scanContactRows(row contactScanner) (domain.Contact, error) { var ( - contactUserID int64 - mutual bool - closeFriend bool - contactPhone string - contactFirstName string - contactLastName string - note string - noteEntitiesJSON string - id int64 - accessHash int64 - phone string - firstName string - lastName string - username string - countryCode string - verified bool - support bool - premiumUntil int64 - emojiStatusDocID int64 - emojiStatusUntil int64 - lastSeenAt int32 + contactUserID int64 + mutual bool + closeFriend bool + contactPhone string + contactFirstName string + contactLastName string + note string + noteEntitiesJSON string + id int64 + accessHash int64 + phone string + firstName string + lastName string + username string + countryCode string + verified bool + support bool + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte + lastSeenAt int32 ) if err := row.Scan( &contactUserID, @@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, ); err != nil { return domain.Contact{}, err @@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { if err != nil { return domain.Contact{}, err } - return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil + return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil } func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { var ( - ownerUserID int64 - mutual bool - closeFriend bool - contactPhone string - contactFirstName string - contactLastName string - note string - noteEntitiesJSON string - id int64 - accessHash int64 - phone string - firstName string - lastName string - username string - countryCode string - verified bool - support bool - premiumUntil int64 - emojiStatusDocID int64 - emojiStatusUntil int64 - lastSeenAt int32 + ownerUserID int64 + mutual bool + closeFriend bool + contactPhone string + contactFirstName string + contactLastName string + note string + noteEntitiesJSON string + id int64 + accessHash int64 + phone string + firstName string + lastName string + username string + countryCode string + verified bool + support bool + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte + lastSeenAt int32 ) if err := row.Scan( &ownerUserID, @@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, ); err != nil { return 0, domain.Contact{}, err @@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { if err != nil { return 0, domain.Contact{}, err } - contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend) + contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend) return ownerUserID, contact, nil } diff --git a/internal/store/postgres/emoji_status_codec_test.go b/internal/store/postgres/emoji_status_codec_test.go new file mode 100644 index 00000000..b7d3faff --- /dev/null +++ b/internal/store/postgres/emoji_status_codec_test.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "testing" + + "telesrv/internal/domain" +) + +func testCollectibleEmojiStatusValue() domain.UserEmojiStatus { + return domain.UserEmojiStatus{ + DocumentID: 101, + Until: 2_000_000_000, + Collectible: domain.EmojiStatusCollectible{ + CollectibleID: 1001, DocumentID: 101, Title: "Gift", Slug: "Gift-1", + PatternDocumentID: 102, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, + }, + } +} + +func TestCollectibleEmojiStatusUserAndEventCodecsRoundTrip(t *testing.T) { + value := testCollectibleEmojiStatusValue() + raw, id, err := encodeEmojiStatusCollectible(value) + if err != nil { + t.Fatalf("encode user collectible: %v", err) + } + if id == nil || *id != value.Collectible.CollectibleID { + t.Fatalf("collectible id = %v", id) + } + if got := mustDecodeEmojiStatusCollectible(id, raw); got != value.Collectible { + t.Fatalf("decoded user collectible = %+v, want %+v", got, value.Collectible) + } + + eventRaw, err := encodeEventEmojiStatus(value) + if err != nil { + t.Fatalf("encode event collectible: %v", err) + } + got, err := decodeEventEmojiStatus(string(eventRaw)) + if err != nil || got != value { + t.Fatalf("decoded event collectible = %+v err=%v, want %+v", got, err, value) + } +} + +func TestCollectibleEmojiStatusCodecRejectsPartialSnapshot(t *testing.T) { + value := domain.UserEmojiStatus{ + DocumentID: 101, + Collectible: domain.EmojiStatusCollectible{CollectibleID: 1001, DocumentID: 101}, + } + if _, _, err := encodeEmojiStatusCollectible(value); err == nil { + t.Fatal("partial user snapshot encoded successfully") + } + if _, err := encodeEventEmojiStatus(value); err == nil { + t.Fatal("partial event snapshot encoded successfully") + } +} diff --git a/internal/store/postgres/emoji_status_event_integration_test.go b/internal/store/postgres/emoji_status_event_integration_test.go new file mode 100644 index 00000000..82042580 --- /dev/null +++ b/internal/store/postgres/emoji_status_event_integration_test.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestUpdateEmojiStatusWithEventIsAtomic(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + u, err := users.Create(ctx, domain.User{ + AccessHash: time.Now().UnixNano(), + Phone: fmt.Sprintf("1666%d", time.Now().UnixNano()), + FirstName: "Emoji status event", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, u.ID) }) + + status := domain.UserEmojiStatus{DocumentID: 42} + event := domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}, + EmojiStatus: status, + Date: int(time.Now().Unix()), + PtsCount: 1, + } + // A nonzero session without its auth-key half violates the outbox + // exclusion-pair invariant. The event failure must roll back the users row. + if _, _, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, [8]byte{}, 77); err == nil { + t.Fatal("UpdateEmojiStatusWithEvent unexpectedly accepted a partial exclusion pair") + } + got, found, err := users.ByID(ctx, u.ID) + if err != nil || !found || !got.EmojiStatus().Empty() { + t.Fatalf("failed aggregate write leaked user state: user=%+v found=%v err=%v", got, found, err) + } + + authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + got, storedEvent, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, authKeyID, 77) + if err != nil { + t.Fatalf("UpdateEmojiStatusWithEvent: %v", err) + } + if got.EmojiStatus() != status || storedEvent.Pts <= 0 || storedEvent.EmojiStatus != status { + t.Fatalf("aggregate result: user=%+v event=%+v", got.EmojiStatus(), storedEvent) + } + loaded, err := NewUpdateEventStore(pool).ListAfter(ctx, u.ID, storedEvent.Pts-1, 1) + if err != nil || len(loaded) != 1 || loaded[0].EmojiStatus != status { + t.Fatalf("durable event: events=%+v err=%v", loaded, err) + } + var outboxCount int + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*) FROM dispatch_outbox +WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, u.ID, storedEvent.Pts).Scan(&outboxCount); err != nil || outboxCount != 1 { + t.Fatalf("dispatch outbox count=%d err=%v", outboxCount, err) + } +} diff --git a/internal/store/postgres/ephemeral_report.go b/internal/store/postgres/ephemeral_report.go new file mode 100644 index 00000000..6513bab3 --- /dev/null +++ b/internal/store/postgres/ephemeral_report.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// EphemeralReportStore persists the low-volume abuse-review evidence path. +// The hot ephemeral send/edit/delete path remains entirely in Redis. +type EphemeralReportStore struct { + db sqlcgen.DBTX +} + +func NewEphemeralReportStore(db sqlcgen.DBTX) *EphemeralReportStore { + return &EphemeralReportStore{db: db} +} + +func (s *EphemeralReportStore) CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (bool, error) { + if s == nil || s.db == nil { + return false, fmt.Errorf("ephemeral report store is not configured") + } + if err := report.Validate(); err != nil { + return false, err + } + evidence, err := json.Marshal(report.Evidence) + if err != nil { + return false, fmt.Errorf("marshal ephemeral report evidence: %w", err) + } + tag, err := s.db.Exec(ctx, ` +INSERT INTO ephemeral_abuse_reports ( + reporter_user_id, channel_id, ephemeral_message_id, sender_user_id, + receiver_user_id, report_option, report_comment, comment_hash, + payload_hash, evidence, created_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11) +ON CONFLICT ( + reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash +) DO NOTHING +`, report.ReporterUserID, report.Evidence.Peer.ID, report.Evidence.MessageID, + report.Evidence.SenderUserID, report.Evidence.ReceiverUserID, + report.Option, report.Comment, report.CommentHash[:], report.Evidence.PayloadHash[:], evidence, report.CreatedAt) + if err != nil { + return false, fmt.Errorf("insert ephemeral abuse report: %w", err) + } + return tag.RowsAffected() == 1, nil +} diff --git a/internal/store/postgres/ephemeral_report_integration_test.go b/internal/store/postgres/ephemeral_report_integration_test.go new file mode 100644 index 00000000..63cb5843 --- /dev/null +++ b/internal/store/postgres/ephemeral_report_integration_test.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "context" + "encoding/json" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestEphemeralReportStoreDurableEvidenceAndIdempotency(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + now := time.Now() + reporter := now.UnixNano()&0x3fffffff + 1000 + sender := reporter + 1 + messageID := int(now.UnixNano()&0x3fffffff) + 1 + message := domain.EphemeralMessage{ + ID: messageID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: reporter + 2}, + SenderUserID: sender, ReceiverUserID: reporter, Date: int(now.Unix()), RandomID: 99, + Content: domain.EphemeralContent{Message: "abuse evidence"}, + OriginDevice: domain.EphemeralDevice{UserID: reporter, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + report := domain.NewEphemeralAbuseReport(reporter, "spam", "review this", message, now) + store := NewEphemeralReportStore(pool) + created, err := store.CreateEphemeralReport(ctx, report) + if err != nil || !created { + t.Fatalf("create=%v err=%v", created, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM ephemeral_abuse_reports WHERE reporter_user_id = $1", reporter) + }) + if created, err := store.CreateEphemeralReport(ctx, report); err != nil || created { + t.Fatalf("retry create=%v err=%v", created, err) + } + var evidenceRaw []byte + var count int + if err := pool.QueryRow(ctx, ` +SELECT evidence, count(*) OVER () +FROM ephemeral_abuse_reports +WHERE reporter_user_id = $1 AND channel_id = $2 AND ephemeral_message_id = $3 +`, reporter, message.Peer.ID, message.ID).Scan(&evidenceRaw, &count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rows=%d", count) + } + var evidence map[string]any + if err := json.Unmarshal(evidenceRaw, &evidence); err != nil { + t.Fatal(err) + } + if evidence["MessageID"] != float64(message.ID) || evidence["Content"] == nil { + t.Fatalf("evidence=%s", evidenceRaw) + } + if _, leaked := evidence["OriginDevice"]; leaked { + t.Fatalf("device identity leaked into report evidence: %s", evidenceRaw) + } +} diff --git a/internal/store/postgres/history_ttl.go b/internal/store/postgres/history_ttl.go index e2090164..9d98fcec 100644 --- a/internal/store/postgres/history_ttl.go +++ b/internal/store/postgres/history_ttl.go @@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int return 0, nil } var period int - err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period) + err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period) if errors.Is(err, pgx.ErrNoRows) { return 0, nil } @@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p UPDATE users SET default_history_ttl_period = $2, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL `, userID, period) if err != nil { return fmt.Errorf("set default history ttl: %w", err) diff --git a/internal/store/postgres/login_code_delivery_integration_test.go b/internal/store/postgres/login_code_delivery_integration_test.go index 5d48c2a9..c156bd7e 100644 --- a/internal/store/postgres/login_code_delivery_integration_test.go +++ b/internal/store/postgres/login_code_delivery_integration_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "strings" "sync" "sync/atomic" "testing" @@ -208,7 +209,7 @@ func TestLoginCodeDeliveryPostgresCommitAckLossRecoversFromReceipt(t *testing.T) } } -func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *testing.T) { +func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialIdentity(t *testing.T) { pool := testPool(t) ctx := context.Background() firstUser := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-row-first") @@ -222,6 +223,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test if err := pool.QueryRow(ctx, `SELECT xmin::text FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&xminBefore); err != nil { t.Fatalf("load official user xmin: %v", err) } + var usernameBefore, usernameXminBefore string + if err := pool.QueryRow(ctx, ` +SELECT username_lower, xmin::text +FROM peer_usernames +WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameBefore, &usernameXminBefore); err != nil { + t.Fatalf("load official username identity: %v", err) + } + if want := strings.ToLower(domain.OfficialSystemUser().Username); usernameBefore != want { + t.Fatalf("official username = %q, want %q", usernameBefore, want) + } const workers = 12 users := make([]domain.User, workers) @@ -256,6 +267,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test if xminAfter != xminBefore { t.Fatalf("official system user row was rewritten: xmin %s -> %s", xminBefore, xminAfter) } + var usernameAfter, usernameXminAfter string + if err := pool.QueryRow(ctx, ` +SELECT username_lower, xmin::text +FROM peer_usernames +WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameAfter, &usernameXminAfter); err != nil { + t.Fatalf("reload official username identity: %v", err) + } + if usernameAfter != usernameBefore || usernameXminAfter != usernameXminBefore { + t.Fatalf("official username identity was rewritten: %q/%s -> %q/%s", usernameBefore, usernameXminBefore, usernameAfter, usernameXminAfter) + } } func TestLoginCodeDeliveryPostgresReceiptRetentionIsBoundedAndSeekOrdered(t *testing.T) { diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index 52308aa5..22b6789b 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool return photo, true, nil } +// GetPhotos resolves a bounded set of immutable photo metadata with one indexed +// ANY query. Missing ids are omitted and the result follows first-seen caller order. +func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) { + if len(ids) == 0 { + return nil, nil + } + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text +FROM photos +WHERE id = ANY($1::bigint[]) +`, unique) + if err != nil { + return nil, err + } + defer rows.Close() + byID := make(map[int64]domain.Photo, len(unique)) + for rows.Next() { + photo, err := scanPhotoRow(rows) + if err != nil { + return nil, err + } + byID[photo.ID] = photo + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.Photo, 0, len(byID)) + for _, id := range unique { + if photo, ok := byID[id]; ok { + out = append(out, photo) + } + } + return out, nil +} + type photoScanner interface { Scan(dest ...any) error } diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go index 1ffc06f0..98aa688b 100644 --- a/internal/store/postgres/media_codec.go +++ b/internal/store/postgres/media_codec.go @@ -2,6 +2,7 @@ package postgres import ( "encoding/json" + "fmt" "reflect" "telesrv/internal/domain" @@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) { return &m, nil } -// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。 +// encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。 // callback data 是 []byte,json.Marshal 自动 base64(保证经 JSONB 字节级 round-trip)。 func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) { + if err := domain.ValidateReplyMarkup(m); err != nil { + return nil, fmt.Errorf("encode reply markup: %w", err) + } if m.IsZero() { return []byte("{}"), nil } @@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) { if err := json.Unmarshal([]byte(s), &m); err != nil { return nil, err } + if err := domain.ValidateReplyMarkup(&m); err != nil { + return nil, fmt.Errorf("decode reply markup: %w", err) + } if m.IsZero() { return nil, nil } diff --git a/internal/store/postgres/media_integration_test.go b/internal/store/postgres/media_integration_test.go index 39e0b650..5d89cb80 100644 --- a/internal/store/postgres/media_integration_test.go +++ b/internal/store/postgres/media_integration_test.go @@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) { if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" { t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto) } + photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99}) + if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 { + t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err) + } // ---- sticker set ---- set := domain.StickerSet{ diff --git a/internal/store/postgres/message_delete.go b/internal/store/postgres/message_delete.go index 862b2da4..3d4406ef 100644 --- a/internal/store/postgres/message_delete.go +++ b/internal/store/postgres/message_delete.go @@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do event.PtsCount = 1 } if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: event.UserID, - Pts: int32(event.Pts), - PtsCount: int32(event.PtsCount), - Date: int32(event.Date), - EventType: string(domain.UpdateEventDeleteMessages), - EventPeers: []byte("[]"), - PeerSettings: []byte("{}"), - MessageIds: messageIDs, - DialogFilter: []byte("{}"), - FilterOrder: []byte("[]"), - FolderPeers: []byte("[]"), - StoryPayload: []byte("{}"), - ReactionPayload: []byte("{}"), + UserID: event.UserID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(domain.UpdateEventDeleteMessages), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: messageIDs, + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + EmojiStatusPayload: []byte("{}"), }); err != nil { return fmt.Errorf("append delete messages event: %w", err) } diff --git a/internal/store/postgres/message_history.go b/internal/store/postgres/message_history.go index b9497606..e9a8151c 100644 --- a/internal/store/postgres/message_history.go +++ b/internal/store/postgres/message_history.go @@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d return out, nil } +// GetByUID resolves one owner's box row by the indexed shared private_message_id. +func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) { + if userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: userID, + PrivateMessageID: uid, + }) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Message{}, false, nil + } + if err != nil { + return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err) + } + if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil { + return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err) + } + return messageFromGetBoxRow(row), true, nil +} + func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { limit := filter.Limit if limit <= 0 { @@ -79,21 +100,23 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma var rows []sqlcgen.ListMessagesByUserRow if addOffset >= 0 { bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, - OffsetDate: pgInt32NonNegative(filter.OffsetDate), - OffsetID: pgInt32NonNegative(filter.OffsetID), - RowOffset: pgInt32Bounded(addOffset), - LimitCount: int32(queryLimit), + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + OffsetID: pgInt32NonNegative(filter.OffsetID), + RowOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err) @@ -104,17 +127,19 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma } if filter.NeedTotalCount { total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, }) if err != nil { return domain.MessageList{}, fmt.Errorf("count messages: %w", err) @@ -128,22 +153,24 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma } else { var err error rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - OffsetID: pgInt32NonNegative(filter.OffsetID), - OffsetDate: pgInt32NonNegative(filter.OffsetDate), - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - AddOffset: pgInt32Bounded(addOffset), - LimitCount: int32(queryLimit), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - NeedTotalCount: filter.NeedTotalCount, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + AddOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + NeedTotalCount: filter.NeedTotalCount, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages: %w", err) diff --git a/internal/store/postgres/message_markup_codec_test.go b/internal/store/postgres/message_markup_codec_test.go new file mode 100644 index 00000000..9bf83865 --- /dev/null +++ b/internal/store/postgres/message_markup_codec_test.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestReplyMarkupCodecValidatesTaggedUnion(t *testing.T) { + keyboard := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + Placeholder: "Choose", + } + raw, err := encodeReplyMarkup(keyboard) + if err != nil { + t.Fatalf("encode reply keyboard: %v", err) + } + got, err := decodeReplyMarkup(string(raw)) + if err != nil || got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || + len(got.Keyboard) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || got.Placeholder != "Choose" { + t.Fatalf("decoded reply keyboard = %#v, err=%v", got, err) + } + + // Pre-union inline snapshots intentionally remain readable. + legacy, err := decodeReplyMarkup(`{"inline":[[{"type":"callback","text":"OK","data":"b2s="}]]}`) + if err != nil || legacy == nil || legacy.Kind() != domain.MessageReplyMarkupInline { + t.Fatalf("legacy inline markup = %#v, err=%v", legacy, err) + } + + malformed := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupInline, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "wrong"}}}, + } + if _, err := encodeReplyMarkup(malformed); err == nil { + t.Fatal("malformed union must fail at the write boundary") + } + if _, err := decodeReplyMarkup(`{"type":"inline","keyboard":[[{"type":"text","text":"wrong"}]]}`); err == nil { + t.Fatal("malformed stored union must fail at the read boundary") + } +} diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index c0436c2d..3ad81aa5 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -66,9 +66,46 @@ func ensureOfficialSystemUserWithDB(ctx context.Context, db sqlcgen.DBTX, msg do return nil } if _, err := db.Exec(ctx, ` -INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) -ON CONFLICT (id) DO NOTHING +WITH desired ( + id, access_hash, phone, first_name, last_name, username, + country_code, verified, support, about, is_bot, bot_info_version +) AS ( + VALUES ($1::bigint, $2::bigint, $3::text, $4::text, $5::text, $6::text, + $7::text, $8::boolean, $9::boolean, $10::text, $11::boolean, $12::integer) +), upserted AS ( + INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version) + SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version + FROM desired + ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + phone = EXCLUDED.phone, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + username = EXCLUDED.username, + country_code = EXCLUDED.country_code, + verified = EXCLUDED.verified, + support = EXCLUDED.support, + about = EXCLUDED.about, + is_bot = EXCLUDED.is_bot, + bot_info_version = EXCLUDED.bot_info_version, + updated_at = now() + WHERE ( + users.access_hash, users.phone, users.first_name, users.last_name, + users.username, users.country_code, users.verified, users.support, + users.about, users.is_bot, users.bot_info_version + ) IS DISTINCT FROM ( + EXCLUDED.access_hash, EXCLUDED.phone, EXCLUDED.first_name, EXCLUDED.last_name, + EXCLUDED.username, EXCLUDED.country_code, EXCLUDED.verified, EXCLUDED.support, + EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version + ) +) +INSERT INTO peer_usernames (username_lower, peer_type, peer_id) +SELECT lower(username), 'user', id +FROM desired +ON CONFLICT (peer_type, peer_id) DO UPDATE SET + username_lower = EXCLUDED.username_lower, + updated_at = now() +WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower `, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil { return fmt.Errorf("ensure official system user: %w", err) } @@ -117,7 +154,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP if err != nil { return domain.SendPrivateTextResult{}, err } - // reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 + // reply_markup(bot reply/inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) if err != nil { return domain.SendPrivateTextResult{}, err @@ -656,22 +693,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M peerType := string(msg.Peer.Type) peerID := msg.Peer.ID if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: msg.OwnerUserID, - Pts: int32(msg.Pts), - PtsCount: 1, - Date: int32(msg.Date), - EventType: string(domain.UpdateEventNewMessage), - EventPeers: []byte("[]"), - PeerSettings: []byte("{}"), - MessageIds: []byte("[]"), - DialogFilter: []byte("{}"), - FilterOrder: []byte("[]"), - FolderPeers: []byte("[]"), - StoryPayload: []byte("{}"), - ReactionPayload: []byte("{}"), - MessageBoxID: &boxID, - PeerType: &peerType, - PeerID: &peerID, + UserID: msg.OwnerUserID, + Pts: int32(msg.Pts), + PtsCount: 1, + Date: int32(msg.Date), + EventType: string(domain.UpdateEventNewMessage), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: []byte("[]"), + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + EmojiStatusPayload: []byte("{}"), + MessageBoxID: &boxID, + PeerType: &peerType, + PeerID: &peerID, }); err != nil { return fmt.Errorf("append new message event: %w", err) } diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go index 6462edac..f995fb7f 100644 --- a/internal/store/postgres/message_send_integration_test.go +++ b/internal/store/postgres/message_send_integration_test.go @@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient event", events[0].Message) } +func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "") + recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501} + photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + messages := NewMessageStore(pool) + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 88, Peers: []domain.Peer{requestedPeer}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }}, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertSnapshot := func(name string, msg domain.Message) { + t.Helper() + if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil { + t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media) + } + action := msg.Media.ServiceAction.RequestedPeer + if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer || + len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" || + action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil || + len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 || + !action.NameRequested || !action.UsernameRequested || !action.PhotoRequested { + t.Fatalf("%s requested-peer=%+v", name, action) + } + } + assertSnapshot("sender", got.SenderMessage) + assertSnapshot("recipient", got.RecipientMessage) + + history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10, + }) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("recipient history=%+v err=%v", history, err) + } + assertSnapshot("recipient history", history.Messages[0]) + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil || len(events) != 1 { + t.Fatalf("recipient events=%+v err=%v", events, err) + } + assertSnapshot("recipient event", events[0].Message) +} + func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) { pool := testPool(t) ctx := context.Background() diff --git a/internal/store/postgres/queries/contact.sql b/internal/store/postgres/queries/contact.sql index 7fa76e30..359c043e 100644 --- a/internal/store/postgres/queries/contact.sql +++ b/internal/store/postgres/queries/contact.sql @@ -22,6 +22,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -52,6 +54,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -130,6 +134,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -168,6 +174,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id; diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index bfc8e284..65c30d09 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -537,6 +537,10 @@ base AS NOT MATERIALIZED ( NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') @@ -798,6 +802,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') @@ -840,6 +848,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index 1dc126ea..5112d1c1 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -8,7 +8,7 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[]) ORDER BY id; -- name: GetUserByPhone :one -SELECT * FROM users WHERE phone = $1; +SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL; -- name: GetUserBySignupEmail :one SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''; @@ -16,11 +16,11 @@ SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '' -- name: GetUsersByPhones :many SELECT * FROM users -WHERE phone = ANY(sqlc.arg(phones)::text[]) +WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL ORDER BY id; -- name: GetUserByUsername :one -SELECT * FROM users WHERE lower(username) = lower($1) AND username <> ''; +SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL; -- name: SearchUsers :many WITH matched AS ( @@ -40,12 +40,15 @@ WITH matched AS ( u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.color_set, u.color, u.color_background_emoji_id, u.profile_color_set, u.profile_color, u.profile_color_background_emoji_id, + u.linked_community_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -60,6 +63,7 @@ WITH matched AS ( FROM users u LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id WHERE u.id <> sqlc.arg(current_user_id)::bigint + AND u.deleted_at IS NULL AND sqlc.arg(query_lower)::text <> '' AND ( (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') @@ -88,12 +92,15 @@ SELECT premium_expires_at, emoji_status_document_id, emoji_status_until, + emoji_status_collectible_id, + emoji_status_collectible, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, + linked_community_id, last_seen_at, contact, mutual @@ -110,14 +117,14 @@ RETURNING *; UPDATE users SET username = $2, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL RETURNING *; -- name: UpdateUserLastSeen :exec UPDATE users SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint), updated_at = now() -WHERE id = sqlc.arg(id)::bigint; +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL; -- name: UpdateUserProfile :one UPDATE users @@ -125,14 +132,14 @@ SET first_name = $2, last_name = $3, about = $4, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL RETURNING *; -- name: UpdateUserPhone :one UPDATE users SET phone = sqlc.arg(phone)::text, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserPhoneAndSignupEmail :one @@ -147,14 +154,14 @@ RETURNING *; UPDATE users SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: SetUserVerified :one UPDATE users SET verified = sqlc.arg(verified)::boolean, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: SweepExpiredPremium :many @@ -164,6 +171,7 @@ SET premium_expires_at = NULL, WHERE id IN ( SELECT id FROM users WHERE premium_expires_at IS NOT NULL + AND deleted_at IS NULL AND premium_expires_at <= sqlc.arg(now)::timestamptz ORDER BY premium_expires_at LIMIT sqlc.arg(limit_count)::int @@ -174,8 +182,10 @@ RETURNING *; UPDATE users SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, emoji_status_until = sqlc.arg(emoji_status_until)::bigint, + emoji_status_collectible_id = sqlc.narg(emoji_status_collectible_id)::bigint, + emoji_status_collectible = sqlc.arg(emoji_status_collectible)::jsonb, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserBirthday :one @@ -184,14 +194,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int, birthday_month = sqlc.arg(birthday_month)::int, birthday_year = sqlc.arg(birthday_year)::int, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserPersonalChannel :one UPDATE users SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserColor :one @@ -200,7 +210,7 @@ SET color_set = sqlc.arg(color_set)::boolean, color = sqlc.arg(color)::int, color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserProfileColor :one @@ -209,5 +219,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean, profile_color = sqlc.arg(color)::int, profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index 230b49e8..d3232eb2 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -15,6 +15,7 @@ INSERT INTO user_update_events ( folder_peers, story_payload, reaction_payload, + emoji_status_payload, message_box_id, peer_type, peer_id, @@ -40,6 +41,7 @@ INSERT INTO user_update_events ( sqlc.arg(folder_peers)::jsonb, sqlc.arg(story_payload)::jsonb, sqlc.arg(reaction_payload)::jsonb, + sqlc.arg(emoji_status_payload)::jsonb, sqlc.narg(message_box_id), sqlc.narg(peer_type)::text, sqlc.narg(peer_id)::bigint, @@ -68,6 +70,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -341,6 +344,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go index b4d2dea6..473b56ad 100644 --- a/internal/store/postgres/sqlcgen/bot.sql.go +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { const insertBotUser = `-- name: InsertBotUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) VALUES ($1, '', $2, '', $3, '', TRUE, 1) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type InsertBotUserParams struct { @@ -209,6 +209,13 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err diff --git a/internal/store/postgres/sqlcgen/contact.sql.go b/internal/store/postgres/sqlcgen/contact.sql.go index 9142a2cf..a05989c9 100644 --- a/internal/store/postgres/sqlcgen/contact.sql.go +++ b/internal/store/postgres/sqlcgen/contact.sql.go @@ -67,6 +67,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -80,29 +82,31 @@ type GetContactParams struct { } type GetContactRow struct { - ContactUserID int64 - Mutual bool - CloseFriend bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { @@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ) return i, err @@ -160,6 +166,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u ` type ListContactsByUserRow struct { - ContactUserID int64 - Mutual bool - CloseFriend bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { @@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ); err != nil { return nil, err @@ -270,6 +282,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id @@ -283,29 +297,31 @@ type UpdateContactNoteParams struct { } type UpdateContactNoteRow struct { - ContactUserID int64 - Mutual bool - CloseFriend bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { @@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ) return i, err @@ -416,6 +434,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -433,30 +453,32 @@ type UpsertContactParams struct { } type UpsertContactRow struct { - ContactUserID int64 - Mutual bool - CloseFriend bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 - ReverseMutualChanged bool + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 + ReverseMutualChanged bool } func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { @@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, &i.ReverseMutualChanged, ) diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index 0c22293c..ed0eb1c9 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -19,14 +19,18 @@ WHERE m.owner_user_id = $1::bigint OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) ) AND ( - $5::text = '' - OR m.body ILIKE ('%' || $5::text || '%') + NOT $5::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) ) - AND ($6::int <= 0 OR m.box_id < $6::int) - AND ($7::int <= 0 OR m.box_id > $7::int) - AND (NOT $8::boolean OR m.pinned) AND ( - NOT $9::boolean + $7::text = '' + OR m.body ILIKE ('%' || $7::text || '%') + ) + AND ($8::int <= 0 OR m.box_id < $8::int) + AND ($9::int <= 0 OR m.box_id > $9::int) + AND (NOT $10::boolean OR m.pinned) + AND ( + NOT $11::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -38,23 +42,25 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $10::text = '' - OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + $12::text = '' + OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint) ) ` type CountMessagesByUserParams struct { - OwnerUserID int64 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 } // ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor), @@ -65,6 +71,8 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, @@ -2280,14 +2288,18 @@ WHERE m.owner_user_id = $1::bigint OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) ) AND ( - $5::text = '' - OR m.body ILIKE ('%' || $5::text || '%') + NOT $5::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) ) - AND ($6::int <= 0 OR m.box_id < $6::int) - AND ($7::int <= 0 OR m.box_id > $7::int) - AND (NOT $8::boolean OR m.pinned) AND ( - NOT $9::boolean + $7::text = '' + OR m.body ILIKE ('%' || $7::text || '%') + ) + AND ($8::int <= 0 OR m.box_id < $8::int) + AND ($9::int <= 0 OR m.box_id > $9::int) + AND (NOT $10::boolean OR m.pinned) + AND ( + NOT $11::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2299,34 +2311,36 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $10::text = '' - OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + $12::text = '' + OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint) ) AND ( - ($12::int > 0 AND m.message_date < $12::int) - OR ($12::int <= 0 AND ($13::int <= 0 OR m.box_id < $13::int)) + ($14::int > 0 AND m.message_date < $14::int) + OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int)) ) ORDER BY m.box_id DESC -OFFSET GREATEST($14::int, 0) -LIMIT $15::int +OFFSET GREATEST($16::int, 0) +LIMIT $17::int ` type ListMessagesBackwardParams struct { - OwnerUserID int64 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 - OffsetDate int32 - OffsetID int32 - RowOffset int32 - LimitCount int32 + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 + OffsetDate int32 + OffsetID int32 + RowOffset int32 + LimitCount int32 } type ListMessagesBackwardRow struct { @@ -2416,6 +2430,8 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, @@ -2618,14 +2634,18 @@ base AS NOT MATERIALIZED ( OR (m.peer_type = $7::text AND m.peer_id = $8::bigint) ) AND ( - $9::text = '' - OR m.body ILIKE ('%' || $9::text || '%') + NOT $9::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[])) ) - AND ($10::int <= 0 OR m.box_id < $10::int) - AND ($11::int <= 0 OR m.box_id > $11::int) - AND (NOT $12::boolean OR m.pinned) AND ( - NOT $13::boolean + $11::text = '' + OR m.body ILIKE ('%' || $11::text || '%') + ) + AND ($12::int <= 0 OR m.box_id < $12::int) + AND ($13::int <= 0 OR m.box_id > $13::int) + AND (NOT $14::boolean OR m.pinned) + AND ( + NOT $15::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2637,14 +2657,14 @@ base AS NOT MATERIALIZED ( ) ) AND ( - $14::text = '' - OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint) + $16::text = '' + OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint) ) ), total AS ( SELECT count(*)::int AS total_count FROM base - WHERE $16::boolean + WHERE $18::boolean ), backward AS ( SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at @@ -2791,22 +2811,24 @@ ORDER BY box_id DESC ` type ListMessagesByUserParams struct { - OwnerUserID int64 - OffsetID int32 - OffsetDate int32 - AddOffset int32 - LimitCount int32 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 - NeedTotalCount bool + OwnerUserID int64 + OffsetID int32 + OffsetDate int32 + AddOffset int32 + LimitCount int32 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 + NeedTotalCount bool } type ListMessagesByUserRow struct { @@ -2896,6 +2918,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index a53465eb..648f5a6e 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -8,6 +8,32 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AccountDeletionNotification struct { + ID int64 + TargetUserID int64 + DeletedUserID int64 + Status string + Attempts int32 + NextAttemptAt pgtype.Timestamptz + LeaseUntil pgtype.Timestamptz + LastError string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type AccountDeletionRequest struct { + ID int64 + UserID int64 + RequesterAuthKeyID int64 + State string + Reason string + ConfirmHashDigest []byte + RequestedAt pgtype.Timestamptz + ExecuteAt pgtype.Timestamptz + CompletedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type AccountPassword struct { UserID int64 HasRecovery bool @@ -30,6 +56,7 @@ type AccountPassword struct { RecoveryCode string RecoveryCodeExpiresAt pgtype.Timestamptz LoginEmail string + PasswordChangedAt pgtype.Timestamptz } type AccountPrivacyRule struct { @@ -267,21 +294,49 @@ type Bot struct { } type BotApiUpdate struct { - ID int64 - BotUserID int64 - UpdateKind string - PeerType string - PeerID int64 - MessageID int32 - SourcePts int32 - Date int32 - CreatedAt pgtype.Timestamptz + ID int64 + BotUserID int64 + UpdateKind string + PeerType string + PeerID int64 + MessageID int32 + SourcePts int32 + Date int32 + CreatedAt pgtype.Timestamptz + CallbackQueryID int64 + CallbackUserID int64 + CallbackChatInstance int64 + CallbackData []byte + CallbackInlineDcID int32 + CallbackInlineOwnerID int64 + CallbackInlineMessageID int32 + CallbackInlineAccessHash int64 + EphemeralPayload []byte } type BotApiUpdateState struct { BotUserID int64 ConfirmedUpdateID int64 UpdatedAt pgtype.Timestamptz + AllowedUpdates []string + CursorInitialized bool + PollOwner string + PollExpiresAt pgtype.Timestamptz +} + +type BotApiWebhook struct { + BotUserID int64 + Url string + SecretToken string + MaxConnections int32 + AllowedUpdates []string + FailureCount int32 + LastErrorDate int32 + LastErrorMessage string + NextAttemptAt pgtype.Timestamptz + DeliveryOwner string + DeliveryExpiresAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } type BotApp struct { @@ -448,6 +503,7 @@ type Channel struct { LinkedMonoforumID int64 Wallpaper []byte Verified bool + LinkedCommunityID int64 } type ChannelAdminLogEvent struct { @@ -653,6 +709,8 @@ type ChannelMessage struct { DeleteDate int32 DeleteMessageIds []byte RequestFingerprint []byte + SuggestedPost []byte + PaidMessageStars int64 } type ChannelMessageMedium struct { @@ -694,6 +752,42 @@ type ChannelMessageViewer struct { CreatedAt pgtype.Timestamptz } +type ChannelStarsBalance struct { + ChannelID int64 + Balance int64 + UpdatedAt pgtype.Timestamptz +} + +type ChannelStarsTransaction struct { + ID int64 + ChannelID int64 + ActorUserID int64 + Amount int64 + Reason string + PeerType string + PeerID int64 + GiftID *int64 + Date int32 +} + +type ChannelTonBalance struct { + ChannelID int64 + BalanceNanoton int64 + UpdatedAt pgtype.Timestamptz +} + +type ChannelTonTransaction struct { + ID int64 + ChannelID int64 + ActorUserID int64 + AmountNanoton int64 + Reason string + PeerType string + PeerID int64 + GiftID *int64 + Date int32 +} + type ChannelTopicRead struct { ChannelID int64 UserID int64 @@ -767,6 +861,62 @@ type ChatlistMembership struct { UpdatedAt pgtype.Timestamptz } +type Community struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + DefaultBannedRights []byte + PhotoID int64 + PhotoDcID int32 + PhotoStripped []byte + Date int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type CommunityMember struct { + CommunityID int64 + UserID int64 + Role string + Status string + AdminRights []byte + Rank string + Date int32 + UpdatedAt pgtype.Timestamptz +} + +type CommunityPeerLink struct { + CommunityID int64 + PeerType string + PeerID int64 + Visibility string + CreatedBy int64 + Date int32 + CreatedAt pgtype.Timestamptz +} + +type CommunityPeerLinkRequest struct { + CommunityID int64 + PeerType string + PeerID int64 + RequestedBy int64 + Visibility string + Date int32 + CreatedAt pgtype.Timestamptz +} + +type CommunityUserState struct { + CommunityID int64 + UserID int64 + Collapsed bool + Pinned bool + PinnedOrder int32 + UpdatedAt pgtype.Timestamptz +} + type Contact struct { UserID int64 ContactUserID int64 @@ -942,6 +1092,21 @@ type EncryptedStateEventDelivery struct { AuthKeyID int64 } +type EphemeralAbuseReport struct { + ID int64 + ReporterUserID int64 + ChannelID int64 + EphemeralMessageID int32 + SenderUserID int64 + ReceiverUserID int64 + ReportOption string + ReportComment string + CommentHash []byte + PayloadHash []byte + Evidence []byte + CreatedAt pgtype.Timestamptz +} + type FileBlob struct { LocationKey string Backend string @@ -1156,19 +1321,33 @@ type PasskeyCredential struct { } type PeerStarGift struct { - ID int64 - OwnerPeerID int64 - FromUserID int64 - GiftID int64 - MsgID int32 - GiftDate int32 - NameHidden bool - Unsaved bool - Converted bool - ConvertStars int64 - Message string - OwnerPeerType string - SavedID int64 + ID int64 + OwnerPeerID int64 + FromUserID int64 + GiftID int64 + MsgID int32 + GiftDate int32 + NameHidden bool + Unsaved bool + Converted bool + ConvertStars int64 + Message string + OwnerPeerType string + SavedID int64 + CatalogRevisionID int64 + UniqueGiftID *int64 + UpgradeMsgID int32 + PinnedOrder int32 + PrepaidUpgradeStars int64 + LifecycleStatus string + TransferStars int64 + PrepaidUpgradeHash string + GiftNum int32 + CanExportAt int32 + CanTransferAt int32 + CanResellAt int32 + DropOriginalDetailsStars int64 + CanCraftAt int32 } type PeerTranslationSetting struct { @@ -1417,6 +1596,394 @@ type SeedState struct { UpdatedAt pgtype.Timestamptz } +type StarGiftAuction struct { + GiftID int64 + Slug string + Version int32 + StartDate int32 + EndDate int32 + RoundDuration int32 + GiftsPerRound int32 + TotalRounds int32 + CurrentRound int32 + NextRoundAt int32 + LastGiftNum int32 + GiftsLeft int32 + MinBidAmount int64 + Status string + UpdatedAt pgtype.Timestamptz +} + +type StarGiftAuctionAcquired struct { + ID int64 + GiftID int64 + BidderUserID int64 + RecipientPeerType string + RecipientPeerID int64 + SavedGiftID *int64 + BidAmount int64 + Round int32 + Pos int32 + GiftNum *int32 + AcquiredAt int32 + HideName bool + Message string +} + +type StarGiftAuctionBid struct { + GiftID int64 + BidderUserID int64 + RecipientPeerType string + RecipientPeerID int64 + Amount int64 + BidDate int32 + HideName bool + Message string + Returned bool + AcquiredCount int32 + Active bool + Version int64 +} + +type StarGiftAuctionBidPayment struct { + UserID int64 + FormID int64 + GiftID int64 + BidAmount int64 + BalanceAfter int64 + CreatedAt int32 +} + +type StarGiftCatalog struct { + GiftID int64 + ActiveRevisionID int64 + Enabled bool + SortOrder int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + CollectibleRevisionID *int64 + AvailabilityRemains int32 + AvailabilityResale int64 + ResellMinStars int64 + FirstSaleDate int32 + LastSaleDate int32 +} + +type StarGiftCatalogRevision struct { + ID int64 + GiftID int64 + Revision int32 + Title string + Stars int64 + ConvertStars int64 + DocumentID int64 + AnimationJson []byte + AnimationSha256 []byte + SourceName string + SourceFormat string + Width int32 + Height int32 + FrameRate float64 + InPoint float64 + OutPoint float64 + CreatedBy string + CommandID string + CreatedAt pgtype.Timestamptz + OfficialGiftID *int64 + SourceManifestSha256 []byte + OfficialSource []byte + Limited bool + SoldOut bool + Birthday bool + RequirePremium bool + LimitedPerUser bool + PeerColorAvailable bool + Auction bool + AvailabilityTotal int32 + ReleasedByPeerType *string + ReleasedByPeerID *int64 + PerUserTotal int32 + LockedUntilDate int32 + AuctionSlug string + GiftsPerRound int32 + AuctionStartDate int32 + UpgradeVariants int32 + BackgroundCenterColor *int32 + BackgroundEdgeColor *int32 + BackgroundTextColor *int32 +} + +type StarGiftCollectibleBackdrop struct { + ID int64 + CollectibleRevisionID int64 + Name string + BackdropID int32 + CenterColor int32 + EdgeColor int32 + PatternColor int32 + TextColor int32 + RarityPermille *int32 + SortOrder int32 + RarityKind string +} + +type StarGiftCollectibleModel struct { + ID int64 + CollectibleRevisionID int64 + Name string + DocumentID int64 + AnimationJson []byte + AnimationSha256 []byte + SourceName string + SourceFormat string + Width int32 + Height int32 + FrameRate float64 + InPoint float64 + OutPoint float64 + RarityPermille *int32 + SortOrder int32 + RarityKind string + Crafted bool + OfficialDocumentID *int64 +} + +type StarGiftCollectiblePattern struct { + ID int64 + CollectibleRevisionID int64 + Name string + DocumentID int64 + AnimationJson []byte + AnimationSha256 []byte + SourceName string + SourceFormat string + Width int32 + Height int32 + FrameRate float64 + InPoint float64 + OutPoint float64 + RarityPermille *int32 + SortOrder int32 + RarityKind string + OfficialDocumentID *int64 +} + +type StarGiftCollectibleRevision struct { + ID int64 + GiftID int64 + Revision int32 + UpgradeStars int64 + SupplyTotal int32 + Issued int32 + SlugPrefix string + Status string + CreatedBy string + CommandID string + CreatedAt pgtype.Timestamptz + PublishedAt pgtype.Timestamptz + OfficialGiftID *int64 + SourceManifestSha256 []byte +} + +type StarGiftCollection struct { + CollectionID int32 + OwnerPeerType string + OwnerPeerID int64 + Title string + SortOrder int32 + Hash int64 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type StarGiftCollectionItem struct { + CollectionID int32 + SavedGiftID int64 + SortOrder int32 + CreatedAt pgtype.Timestamptz +} + +type StarGiftConversion struct { + SavedGiftID int64 + ActorUserID int64 + OwnerPeerType string + OwnerPeerID int64 + Amount int64 + BalanceAfter int64 + ConvertedAt int32 +} + +type StarGiftCraftCommand struct { + UserID int64 + CommandKey string + InputUniqueGiftIds []int64 + GiftID int64 + Success bool + ResultUniqueGiftID *int64 + ChancePermille int32 + CreatedAt int32 + SourceEditPts []int32 +} + +type StarGiftDropDetailsCommand struct { + UserID int64 + CommandKey string + SavedGiftID int64 + UniqueGiftID int64 + FormID int64 + ChargeStars int64 + BalanceAfter int64 + CreatedAt int32 +} + +type StarGiftListing struct { + UniqueGiftID int64 + SellerPeerType string + SellerPeerID int64 + Currency string + Amount int64 + ListedAt int32 + UpdatedAt int32 + Version int64 +} + +type StarGiftNotificationSetting struct { + UserID int64 + ChannelID int64 + Enabled bool + UpdatedAt pgtype.Timestamptz +} + +type StarGiftOffer struct { + ID int64 + BuyerUserID int64 + OwnerPeerType string + OwnerPeerID int64 + UniqueGiftID int64 + Currency string + Amount int64 + RandomID int64 + OfferMsgID int32 + BuyerMsgID int32 + Status string + CreatedAt int32 + ExpiresAt int32 + ResolvedAt int32 + BalanceAfter int64 + ResolutionNotified bool +} + +type StarGiftPatternDocumentRepair struct { + OldDocumentID int64 + NewDocumentID int64 + RepairedAt pgtype.Timestamptz +} + +type StarGiftPatternPreviewDocumentRepair struct { + OldDocumentID int64 + NewDocumentID int64 + RepairedAt pgtype.Timestamptz +} + +type StarGiftPrepaidUpgradeCommand struct { + PayerUserID int64 + CommandKey string + SavedGiftID int64 + FormID int64 + ChargeStars int64 + BalanceAfter int64 + CreatedAt int32 +} + +type StarGiftPurchaseCommand struct { + BuyerUserID int64 + CommandKey string + GiftID int64 + RecipientPeerType string + RecipientPeerID int64 + SavedGiftID int64 + FormID int64 + ChargeStars int64 + BalanceAfter int64 + CreatedAt int32 +} + +type StarGiftPurchaseForm struct { + BuyerUserID int64 + FormID int64 + GiftID int64 + RevisionID int64 + RecipientPeerType string + RecipientPeerID int64 + IncludeUpgrade bool + HideName bool + Message string + ChargeStars int64 + IssuedAt int32 + ExpiresAt int32 +} + +type StarGiftSale struct { + ID int64 + UniqueGiftID int64 + SellerPeerType string + SellerPeerID int64 + BuyerPeerType string + BuyerPeerID int64 + Currency string + Amount int64 + CommissionAmount int64 + SoldAt int32 + CommandKey string +} + +type StarGiftTransferCommand struct { + ActorUserID int64 + CommandKey string + UniqueGiftID int64 + FromPeerType string + FromPeerID int64 + ToPeerType string + ToPeerID int64 + ChargeStars int64 + BalanceAfter int64 + CreatedAt int32 +} + +type StarGiftUpgradeCommand struct { + UserID int64 + CommandKey string + SourceSavedGiftID int64 + FormID int64 + UniqueGiftID int64 + BalanceAfter int64 + CreatedAt pgtype.Timestamptz + ChargeStars int64 + RequirePrepaid bool + KeepOriginalDetails bool + SourceEditPts int32 +} + +type StarGiftUserPurchase struct { + UserID int64 + GiftID int64 + PurchasedCount int32 + UpdatedAt pgtype.Timestamptz +} + +type StarGiftWithdrawalRequest struct { + ID int64 + UniqueGiftID int64 + OwnerUserID int64 + Provider string + ProviderRequestID string + Url string + Status string + CreatedAt int32 + ExpiresAt int32 + CompletedAt int32 +} + type StarsBalance struct { UserID int64 Balance int64 @@ -1533,6 +2100,28 @@ type StoryView struct { UpdatedAt pgtype.Timestamptz } +type TelesrvCollectiblePatternCorrectionEvent struct { + UserID int64 + Pts int32 +} + +type TelesrvPatternPreviewCorrectionEvent struct { + UserID int64 + Pts int32 +} + +type TelesrvPatternPreviewRepairedWearer struct { + UserID int64 + OldDocumentID int64 + NewDocumentID int64 +} + +type TelesrvRepairedCollectibleWearer struct { + UserID int64 + OldDocumentID int64 + NewDocumentID int64 +} + type TempAuthKeyBinding struct { TempAuthKeyID int64 PermAuthKeyID int64 @@ -1564,6 +2153,66 @@ type ThemeUserInstall struct { InstalledAt pgtype.Timestamptz } +type TonBalance struct { + UserID int64 + BalanceNanoton int64 + Granted bool + UpdatedAt pgtype.Timestamptz +} + +type TonTransaction struct { + ID int64 + UserID int64 + AmountNanoton int64 + Reason string + PeerType *string + PeerID *int64 + GiftID *int64 + Date int32 +} + +type UniqueStarGift struct { + ID int64 + GiftID int64 + CollectibleRevisionID int64 + SourceSavedGiftID int64 + Title string + Slug string + Num int32 + OwnerPeerType *string + OwnerPeerID *int64 + ModelAttributeID int64 + PatternAttributeID int64 + BackdropAttributeID int64 + KeepOriginalDetails bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + RequirePremium bool + ResaleTonOnly bool + ThemeAvailable bool + Burned bool + Crafted bool + OriginalOwnerPeerType string + OriginalOwnerPeerID int64 + OwnerName string + OwnerAddress string + GiftAddress string + ReleasedByPeerType *string + ReleasedByPeerID *int64 + ValueAmount int64 + ValueCurrency string + ValueUsdAmount int64 + ThemePeerType *string + ThemePeerID *int64 + HostPeerType *string + HostPeerID *int64 + OfferMinStars int32 + CraftChancePermille int32 + LastSaleDate int32 + LastSaleCurrency string + LastSaleAmount int64 +} + type UpdateState struct { AuthKeyID int64 Pts int32 @@ -1627,6 +2276,13 @@ type User struct { BirthdayMonth int32 BirthdayYear int32 PersonalChannelID int64 + DeletedAt pgtype.Timestamptz + DeletionSource string + DeletionReason string + AccountDeleteAt pgtype.Timestamptz + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LinkedCommunityID int64 SignupEmail string } @@ -1703,33 +2359,34 @@ type UserTopReaction struct { } type UserUpdateEvent struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - MessageBoxID *int32 - PeerType *string - PeerID *int64 - MaxID int32 - StillUnreadCount int32 - CreatedAt pgtype.Timestamptz - EventBool bool - EventPeers []byte - PeerSettings []byte - MessageIds []byte - DialogFilter []byte - FilterOrder []byte - FolderPeers []byte - FilterID int32 - TagsEnabled bool - ChannelPts int32 - FolderID int32 - QuickReplies []byte - QuickReplyMessage []byte - StoryPayload []byte - ReactionPayload []byte - EventPhone string + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + MessageBoxID *int32 + PeerType *string + PeerID *int64 + MaxID int32 + StillUnreadCount int32 + CreatedAt pgtype.Timestamptz + EventBool bool + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + FilterID int32 + TagsEnabled bool + ChannelPts int32 + FolderID int32 + QuickReplies []byte + QuickReplyMessage []byte + StoryPayload []byte + ReactionPayload []byte + EventPhone string + EmojiStatusPayload []byte } type UserUpdateRetention struct { @@ -1765,13 +2422,17 @@ type WebviewCustomMethodQuery struct { } type WebviewRequestedButton struct { - WebappReqID string - BotUserID int64 - UserID int64 - ButtonID int32 - Text string - PeerType string - MaxQuantity int32 - CreatedAt pgtype.Timestamptz - ExpiresAt pgtype.Timestamptz + WebappReqID string + BotUserID int64 + UserID int64 + ButtonID int32 + Text string + PeerType string + MaxQuantity int32 + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + PeerFilter []byte + NameRequested bool + UsernameRequested bool + PhotoRequested bool } diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 3d9d52f1..278c46e9 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -14,7 +14,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type CreateUserParams struct { @@ -70,13 +70,20 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE id = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -112,13 +119,20 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err } const getUserByPhone = `-- name: GetUserByPhone :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE phone = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE phone = $1 AND deleted_at IS NULL ` func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { @@ -154,13 +168,20 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err } const getUserBySignupEmail = `-- name: GetUserBySignupEmail :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '' +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '' ` func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) { @@ -196,13 +217,20 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL ` func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { @@ -238,13 +266,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE id = ANY($1::bigint[]) ORDER BY id @@ -289,6 +324,13 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ); err != nil { return nil, err @@ -302,9 +344,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } const getUsersByPhones = `-- name: GetUsersByPhones :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users -WHERE phone = ANY($1::text[]) +WHERE phone = ANY($1::text[]) AND deleted_at IS NULL ORDER BY id ` @@ -347,6 +389,13 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ); err != nil { return nil, err @@ -377,12 +426,15 @@ WITH matched AS ( u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.color_set, u.color, u.color_background_emoji_id, u.profile_color_set, u.profile_color, u.profile_color_background_emoji_id, + u.linked_community_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -397,6 +449,7 @@ WITH matched AS ( FROM users u LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id WHERE u.id <> $4::bigint + AND u.deleted_at IS NULL AND $3::text <> '' AND ( ($2::text <> '' AND u.phone LIKE $2::text || '%') @@ -425,12 +478,15 @@ SELECT premium_expires_at, emoji_status_document_id, emoji_status_until, + emoji_status_collectible_id, + emoji_status_collectible, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, + linked_community_id, last_seen_at, contact, mutual @@ -463,12 +519,15 @@ type SearchUsersRow struct { PremiumExpiresAt pgtype.Timestamptz EmojiStatusDocumentID int64 EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte ColorSet bool Color int32 ColorBackgroundEmojiID int64 ProfileColorSet bool ProfileColor int32 ProfileColorBackgroundEmojiID int64 + LinkedCommunityID int64 LastSeenAt int64 Contact bool Mutual bool @@ -505,12 +564,15 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.ColorSet, &i.Color, &i.ColorBackgroundEmojiID, &i.ProfileColorSet, &i.ProfileColor, &i.ProfileColorBackgroundEmojiID, + &i.LinkedCommunityID, &i.LastSeenAt, &i.Contact, &i.Mutual, @@ -529,8 +591,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one UPDATE users SET premium_expires_at = $1::timestamptz, updated_at = now() -WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $2::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type SetUserPremiumUntilParams struct { @@ -571,6 +633,13 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -580,8 +649,8 @@ const setUserVerified = `-- name: SetUserVerified :one UPDATE users SET verified = $1::boolean, updated_at = now() -WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $2::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type SetUserVerifiedParams struct { @@ -622,6 +691,13 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -634,11 +710,12 @@ SET premium_expires_at = NULL, WHERE id IN ( SELECT id FROM users WHERE premium_expires_at IS NOT NULL + AND deleted_at IS NULL AND premium_expires_at <= $1::timestamptz ORDER BY premium_expires_at LIMIT $2::int ) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type SweepExpiredPremiumParams struct { @@ -685,6 +762,13 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ); err != nil { return nil, err @@ -703,8 +787,8 @@ SET birthday_day = $1::int, birthday_month = $2::int, birthday_year = $3::int, updated_at = now() -WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $4::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserBirthdayParams struct { @@ -752,6 +836,13 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -763,8 +854,8 @@ SET color_set = $1::boolean, color = $2::int, color_background_emoji_id = $3::bigint, updated_at = now() -WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $4::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserColorParams struct { @@ -812,6 +903,13 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -821,19 +919,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one UPDATE users SET emoji_status_document_id = $1::bigint, emoji_status_until = $2::bigint, + emoji_status_collectible_id = $3::bigint, + emoji_status_collectible = $4::jsonb, updated_at = now() -WHERE id = $3::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $5::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserEmojiStatusParams struct { - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - ID int64 + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + ID int64 } func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) { - row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID) + row := q.db.QueryRow(ctx, updateUserEmojiStatus, + arg.EmojiStatusDocumentID, + arg.EmojiStatusUntil, + arg.EmojiStatusCollectibleID, + arg.EmojiStatusCollectible, + arg.ID, + ) var i User err := row.Scan( &i.ID, @@ -865,6 +973,13 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -874,7 +989,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec UPDATE users SET last_seen_at = GREATEST(last_seen_at, $1::bigint), updated_at = now() -WHERE id = $2::bigint +WHERE id = $2::bigint AND deleted_at IS NULL ` type UpdateUserLastSeenParams struct { @@ -891,8 +1006,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one UPDATE users SET personal_channel_id = $1::bigint, updated_at = now() -WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $2::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserPersonalChannelParams struct { @@ -933,6 +1048,13 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -942,8 +1064,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one UPDATE users SET phone = $1::text, updated_at = now() -WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $2::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserPhoneParams struct { @@ -984,6 +1106,13 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -995,7 +1124,7 @@ SET phone = $1::text, signup_email = $2::text, updated_at = now() WHERE id = $3::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserPhoneAndSignupEmailParams struct { @@ -1037,6 +1166,13 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -1048,8 +1184,8 @@ SET first_name = $2, last_name = $3, about = $4, updated_at = now() -WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $1 AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserProfileParams struct { @@ -1097,6 +1233,13 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -1108,8 +1251,8 @@ SET profile_color_set = $1::boolean, profile_color = $2::int, profile_color_background_emoji_id = $3::bigint, updated_at = now() -WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $4::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserProfileColorParams struct { @@ -1157,6 +1300,13 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err @@ -1166,8 +1316,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one UPDATE users SET username = $2, updated_at = now() -WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +WHERE id = $1 AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email ` type UpdateUserUsernameParams struct { @@ -1208,6 +1358,13 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, + &i.LinkedCommunityID, &i.SignupEmail, ) return i, err diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go index c864c11c..cf57ce80 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -26,6 +26,7 @@ INSERT INTO user_update_events ( folder_peers, story_payload, reaction_payload, + emoji_status_payload, message_box_id, peer_type, peer_id, @@ -51,43 +52,45 @@ INSERT INTO user_update_events ( $13::jsonb, $14::jsonb, $15::jsonb, - $16, - $17::text, - $18::bigint, - $19::int, + $16::jsonb, + $17, + $18::text, + $19::bigint, $20::int, $21::int, $22::int, - $23::boolean, - $24::int + $23::int, + $24::boolean, + $25::int ) ` type AppendUserUpdateEventParams struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - EventBool bool - EventPhone string - EventPeers []byte - PeerSettings []byte - MessageIds []byte - DialogFilter []byte - FilterOrder []byte - FolderPeers []byte - StoryPayload []byte - ReactionPayload []byte - MessageBoxID *int32 - PeerType *string - PeerID *int64 - FilterID int32 - MaxID int32 - StillUnreadCount int32 - ChannelPts int32 - TagsEnabled bool - FolderID int32 + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPhone string + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + StoryPayload []byte + ReactionPayload []byte + EmojiStatusPayload []byte + MessageBoxID *int32 + PeerType *string + PeerID *int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + ChannelPts int32 + TagsEnabled bool + FolderID int32 } func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { @@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat arg.FolderPeers, arg.StoryPayload, arg.ReactionPayload, + arg.EmojiStatusPayload, arg.MessageBoxID, arg.PeerType, arg.PeerID, @@ -137,6 +141,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct { FolderPeersJson string StoryPayloadJson string ReactionPayloadJson string + EmojiStatusPayloadJson string EventPeerType string EventPeerID int64 FilterID int32 @@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.FolderPeersJson, &i.StoryPayloadJson, &i.ReactionPayloadJson, + &i.EmojiStatusPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, @@ -788,6 +795,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct { FolderPeersJson string StoryPayloadJson string ReactionPayloadJson string + EmojiStatusPayloadJson string EventPeerType string EventPeerID int64 FilterID int32 @@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.FolderPeersJson, &i.StoryPayloadJson, &i.ReactionPayloadJson, + &i.EmojiStatusPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, diff --git a/internal/store/postgres/star_gift.go b/internal/store/postgres/star_gift.go index f334c676..7f83fdb3 100644 --- a/internal/store/postgres/star_gift.go +++ b/internal/store/postgres/star_gift.go @@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore { const starGiftCatalogSelect = ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text @@ -75,6 +85,16 @@ func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) ( } gift, err := scanCatalogGift(s.db.QueryRow(ctx, ` SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text @@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID)) func scanCatalogGift(row rowScanner) (domain.StarGift, error) { var gift domain.StarGift var attrsJSON, thumbsJSON string + var releasedByType string + var releasedByID int64 + var hasBackground bool + var background domain.StarGiftBackground if err := row.Scan( &gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title, + &gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium, + &gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction, + &gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale, + &gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars, + &releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate, + &gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants, + &hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor, &gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued, &gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date, &gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON, ); err != nil { return domain.StarGift{}, err } + if releasedByType != "" && releasedByID > 0 { + gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} + } + if hasBackground { + gift.Background = &background + } + if gift.LimitedPerUser { + gift.PerUserRemains = gift.PerUserTotal + } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err) @@ -148,8 +188,12 @@ func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain. return fmt.Errorf("allocate star gift id: %w", err) } if _, err := tx.Exec(ctx, ` -INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order) -VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { +INSERT INTO star_gift_catalog ( + gift_id, active_revision_id, enabled, sort_order, availability_remains, + availability_resale, resell_min_stars, first_sale_date, last_sale_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder, + write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars, + write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("insert star gift catalog: %w", err) } } else { @@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil { INSERT INTO star_gift_catalog_revisions ( id, gift_id, revision, title, stars, convert_stars, document_id, animation_json, animation_sha256, source_name, source_format, - width, height, frame_rate, in_point, out_point, created_by, command_id -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + width, height, frame_rate, in_point, out_point, created_by, command_id, + official_gift_id, source_manifest_sha256, official_source, + limited, sold_out, birthday, require_premium, limited_per_user, + peer_color_available, auction, availability_total, + released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date, + auction_slug, gifts_per_round, auction_start_date, upgrade_variants, + background_center_color, background_edge_color, background_text_color +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, + NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29, + $30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40 +)`, revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID, string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat), write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint, - write.Actor, write.CommandID, + write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON), + write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser, + write.PeerColorAvailable, write.Auction, write.AvailabilityTotal, + nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal, + write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate, + write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"), + nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"), ); err != nil { return fmt.Errorf("insert star gift revision: %w", err) } if write.GiftID != 0 { if _, err := tx.Exec(ctx, ` UPDATE star_gift_catalog -SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now() -WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil { +SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5, + availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now() +WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains, + write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("activate star gift revision: %w", err) } } @@ -208,6 +270,62 @@ WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != n return entry, nil } +func nullableStarGiftPeerType(peer domain.Peer) any { + if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return nil + } + return string(peer.Type) +} + +func nullableStarGiftPeerID(peer domain.Peer) any { + if nullableStarGiftPeerType(peer) == nil { + return nil + } + return peer.ID +} + +func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any { + if background == nil { + return nil + } + switch component { + case "center": + return background.CenterColor + case "edge": + return background.EdgeColor + default: + return background.TextColor + } +} + +func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { + var result domain.StarGiftCatalogBundleResult + err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error { + nested := NewStarGiftStore(tx) + entry, err := nested.CreateCatalogRevision(ctx, write.Catalog) + if err != nil { + return err + } + result.Catalog = entry + if write.Collectible != nil { + collectibleWrite := *write.Collectible + collectibleWrite.GiftID = entry.Gift.ID + revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite) + if err != nil { + return err + } + result.Collectible = &revision + entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID) + if err != nil { + return err + } + result.Catalog = entry + } + return nil + }) + return result, err +} + func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) { tag, err := s.db.Exec(ctx, ` UPDATE star_gift_catalog SET enabled=$2, updated_at=now() @@ -267,6 +385,16 @@ WHERE c.gift_id=$1`, giftID).Scan(&raw) func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) { row := db.QueryRow(ctx, ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, + r.limited, r.sold_out, r.birthday, r.require_premium, + r.limited_per_user, r.peer_color_available, r.auction, + c.availability_remains, r.availability_total, c.availability_resale, + c.first_sale_date, c.last_sale_date, c.resell_min_stars, + COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), + r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, + r.auction_start_date, r.upgrade_variants, + r.background_center_color IS NOT NULL, + COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), + COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text, @@ -280,8 +408,20 @@ JOIN documents d ON d.id=r.document_id WHERE c.gift_id=$1`, giftID) var entry domain.StarGiftCatalogEntry var attrsJSON, thumbsJSON, sourceFormat string + var releasedByType string + var releasedByID int64 + var hasBackground bool + var background domain.StarGiftBackground if err := row.Scan( &entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title, + &entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium, + &entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction, + &entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale, + &entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars, + &releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate, + &entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate, + &entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor, + &background.TextColor, &entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued, &entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date, &entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON, @@ -291,6 +431,15 @@ WHERE c.gift_id=$1`, giftID) ); err != nil { return domain.StarGiftCatalogEntry{}, err } + if releasedByType != "" && releasedByID > 0 { + entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} + } + if hasBackground { + entry.Gift.Background = &background + } + if entry.Gift.LimitedPerUser { + entry.Gift.PerUserRemains = entry.Gift.PerUserTotal + } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGiftCatalogEntry{}, err @@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) ( WITH next_id AS ( SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id ) -INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message) +INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message) SELECT next_id.id, $1,$2,$3,$4,$5,$6, CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END, - $8,$9,$10,false,$11,$12,$13 + $8,$9,$10,false,$11,$12,$13,$14,$15 FROM next_id RETURNING id`, string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date, - gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id) + gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id) if err != nil { return 0, fmt.Errorf("create star gift: %w", err) } @@ -347,7 +496,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.S JOIN star_gift_catalog c ON c.gift_id = p.gift_id LEFT JOIN star_gift_collectible_revisions acr ON acr.id = c.collectible_revision_id AND acr.status = 'published'` - conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"} + conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"} args := []any{string(owner.Type), owner.ID} if filter.ExcludeUnsaved { conditions = append(conditions, "NOT p.unsaved") @@ -386,15 +535,35 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d } page := domain.SavedStarGiftPage{Count: total} - if cursor, ok := domain.DecodeStarGiftCursor(offset); ok { - args = append(args, cursor) - where += fmt.Sprintf(" AND p.id < $%d", len(args)) + profileOrder := filter.CollectionID == 0 + if cursor, ok := domain.DecodeSavedStarGiftListCursor(offset); ok { + if profileOrder && cursor.PinnedOrder > 0 { + args = append(args, cursor.PinnedOrder, cursor.ID) + where += fmt.Sprintf(` AND ( + p.pinned_order = 0 + OR p.pinned_order > $%d + OR (p.pinned_order = $%d AND p.id < $%d) +)`, len(args)-1, len(args)-1, len(args)) + } else { + args = append(args, cursor.ID) + if profileOrder { + where += fmt.Sprintf(" AND p.pinned_order = 0 AND p.id < $%d", len(args)) + } else { + where += fmt.Sprintf(" AND p.id < $%d", len(args)) + } + } + } + orderBy := "ORDER BY p.id DESC" + if profileOrder { + orderBy = "ORDER BY (p.pinned_order = 0), p.pinned_order, p.id DESC" } args = append(args, limit+1) limitPlaceholder := len(args) rows, err := s.db.Query(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -402,7 +571,7 @@ SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.ca WHERE i.saved_gift_id=p.id), ARRAY[]::integer[]) FROM peer_star_gifts p `+joins+` WHERE `+where+` -ORDER BY p.id DESC +`+orderBy+` LIMIT $`+fmt.Sprint(limitPlaceholder), args...) if err != nil { return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err) @@ -421,7 +590,12 @@ LIMIT $`+fmt.Sprint(limitPlaceholder), args...) } if len(gifts) > limit { gifts = gifts[:limit] - page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID) + last := gifts[len(gifts)-1] + pinnedOrder := 0 + if profileOrder { + pinnedOrder = last.PinnedOrder + } + page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID) } page.Gifts = gifts return page, nil @@ -434,47 +608,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, if len(refs) == 0 { return []int64{}, nil } + type resolveKey struct { + value int64 + slug string + } + keys := make([]resolveKey, 0, len(refs)) values := make([]int64, 0, len(refs)) - seenValues := make(map[int64]struct{}, len(refs)) - column := "msg_id" + slugs := make([]string, 0, len(refs)) + seenKeys := make(map[string]struct{}, len(refs)) for _, ref := range refs { if ref.Owner != owner || !ref.Valid() { return nil, domain.ErrStarGiftNotFound } + if ref.Slug != "" { + slug := strings.ToLower(strings.TrimSpace(ref.Slug)) + key := "slug:" + slug + if _, duplicate := seenKeys[key]; duplicate { + return nil, domain.ErrStarGiftCollectibleInvalid + } + seenKeys[key] = struct{}{} + keys = append(keys, resolveKey{slug: slug}) + slugs = append(slugs, slug) + continue + } value := int64(ref.MsgID) if owner.Type == domain.PeerTypeChannel { - column = "saved_id" value = ref.SavedID } - if _, duplicate := seenValues[value]; duplicate { + key := fmt.Sprintf("id:%d", value) + if _, duplicate := seenKeys[key]; duplicate { return nil, domain.ErrStarGiftCollectibleInvalid } - seenValues[value] = struct{}{} + seenKeys[key] = struct{}{} + keys = append(keys, resolveKey{value: value}) values = append(values, value) } - rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts -WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values) + query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id +FROM peer_star_gifts p +LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id +WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' + AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` + if owner.Type == domain.PeerTypeUser { + query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id +FROM peer_star_gifts p +LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id +WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' + AND (p.msg_id::bigint=ANY($3::bigint[]) + OR u.slug=ANY($4::text[]))` + } + rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs) if err != nil { return nil, fmt.Errorf("resolve saved star gifts: %w", err) } defer rows.Close() - resolved := make(map[int64]int64, len(values)) + resolvedValues := make(map[int64]int64, len(values)) + resolvedSlugs := make(map[string]int64, len(slugs)) for rows.Next() { - var value, id int64 - if err := rows.Scan(&value, &id); err != nil { + var primaryValue, id int64 + var slug string + if err := rows.Scan(&primaryValue, &slug, &id); err != nil { return nil, fmt.Errorf("scan resolved saved star gift: %w", err) } - resolved[value] = id + if existing := resolvedValues[primaryValue]; existing != 0 && existing != id { + return nil, domain.ErrStarGiftCollectibleInvalid + } + resolvedValues[primaryValue] = id + if slug != "" { + if existing := resolvedSlugs[slug]; existing != 0 && existing != id { + return nil, domain.ErrStarGiftCollectibleInvalid + } + resolvedSlugs[slug] = id + } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err) } - out := make([]int64, 0, len(values)) - for _, value := range values { - id := resolved[value] + out := make([]int64, 0, len(keys)) + seenIDs := make(map[int64]struct{}, len(keys)) + for _, key := range keys { + id := resolvedValues[key.value] + if key.slug != "" { + id = resolvedSlugs[key.slug] + } if id == 0 { return nil, domain.ErrStarGiftNotFound } + if _, duplicate := seenIDs[id]; duplicate { + return nil, domain.ErrStarGiftCollectibleInvalid + } + seenIDs[id] = struct{}{} out = append(out, id) } return out, nil @@ -487,7 +709,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe where, args := savedStarGiftRefWhere(ref) row := s.db.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -510,7 +734,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in return 0, nil } var n int - if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { return 0, fmt.Errorf("count star gifts: %w", err) } return n, nil @@ -524,7 +748,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift args = append(args, unsaved) tag, err := s.db.Exec(ctx, ` UPDATE peer_star_gifts SET unsaved = $4 -WHERE `+where+` AND NOT converted`, args...) +WHERE `+where+` AND lifecycle_status='active'`, args...) if err != nil { return false, fmt.Errorf("set star gift unsaved: %w", err) } @@ -543,7 +767,9 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG where, args := savedStarGiftRefWhere(ref) row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -564,13 +790,14 @@ WHERE `+where+` FOR UPDATE`, args...) if g.UniqueGiftID != 0 { return domain.ErrStarGiftAlreadyUpgraded } - if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil { + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil { return fmt.Errorf("mark star gift converted: %w", err) } if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil { return err } g.Converted = true + g.LifecycleStatus = domain.StarGiftLifecycleConverted g.Unsaved = true g.PinnedOrder = 0 g.CollectionIDs = nil @@ -587,7 +814,9 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { var g domain.SavedStarGift var ownerType string if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date, - &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID, + &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum, + &g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt, + &g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID, &g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil { return domain.SavedStarGift{}, err } @@ -597,6 +826,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { args := []any{string(ref.Owner.Type), ref.Owner.ID} + if ref.Slug != "" { + args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug))) + return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args + } switch ref.Owner.Type { case domain.PeerTypeChannel: args = append(args, ref.SavedID) diff --git a/internal/store/postgres/star_gift_collectibles.go b/internal/store/postgres/star_gift_collectibles.go index 2d2a2be6..7bad16aa 100644 --- a/internal/store/postgres/star_gift_collectibles.go +++ b/internal/store/postgres/star_gift_collectibles.go @@ -14,6 +14,34 @@ import ( "telesrv/internal/store/postgres/sqlcgen" ) +func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any { + if attribute.RarityKind != domain.StarGiftRarityPermille { + return nil + } + return attribute.RarityPermille +} + +func nullableSHA256(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +func nullablePositiveInt64(value int64) any { + if value <= 0 { + return nil + } + return value +} + +func nullableOfficialGiftJSON(value []byte) any { + if len(value) == 0 { + return nil + } + return string(value) +} + func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) { write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix)) write.Actor = strings.TrimSpace(write.Actor) @@ -38,13 +66,15 @@ SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE var revisionID int64 if err := tx.QueryRow(ctx, ` INSERT INTO star_gift_collectible_revisions - (gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id) -VALUES ($1,$2,$3,$4,$5,'draft',$6,$7) -RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil { + (gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id, + official_gift_id, source_manifest_sha256) +VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9) +RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID, + write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil { return fmt.Errorf("insert collectible revision: %w", err) } media := NewMediaStore(tx) - insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error { + insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error { for _, attribute := range attributes { if err := media.PutDocument(ctx, *attribute.Document); err != nil { return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err) @@ -53,35 +83,53 @@ RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, wr return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err) } animation := attribute.Animation - query := fmt.Sprintf(` + var query string + if models { + query = fmt.Sprintf(` INSERT INTO %s (collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format, width, height, frame_rate, in_point, out_point, - rarity_permille, sort_order) -VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table) - if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, - string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), - animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, - attribute.RarityPermille, attribute.SortOrder); err != nil { - return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + rarity_kind, rarity_permille, crafted, official_document_id, sort_order) +VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table) + if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, + string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), + animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, + string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted, + nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil { + return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + } + } else { + query = fmt.Sprintf(` +INSERT INTO %s + (collectible_revision_id, name, document_id, animation_json, animation_sha256, + source_name, source_format, width, height, frame_rate, in_point, out_point, + rarity_kind, rarity_permille, official_document_id, sort_order) +VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table) + if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID, + string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat), + animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint, + string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID), + attribute.SortOrder); err != nil { + return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err) + } } } return nil } - if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil { + if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil { return err } - if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil { + if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil { return err } for _, attribute := range write.Backdrops { if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_collectible_backdrops (collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, - text_color, rarity_permille, sort_order) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID, + text_color, rarity_kind, rarity_permille, sort_order) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID, attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor, - attribute.RarityPermille, attribute.SortOrder); err != nil { + string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil { return fmt.Errorf("insert collectible backdrop: %w", err) } } @@ -152,10 +200,11 @@ func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID in var publishedAt pgtype.Timestamptz if err := db.QueryRow(ctx, ` SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status, - created_by, created_at, published_at + created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256 FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan( &revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt, + &revision.OfficialGiftID, &revision.SourceManifestSHA256, ); err != nil { return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err) } @@ -184,13 +233,20 @@ func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, rev return nil, domain.ErrStarGiftCollectibleInvalid } rows, err := db.Query(ctx, fmt.Sprintf(` -SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order, +SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0), + %s, COALESCE(a.official_document_id,0), a.sort_order, a.animation_json::text, a.animation_sha256, a.source_name, a.source_format, a.width, a.height, a.frame_rate, a.in_point, a.out_point, d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text FROM %s a JOIN documents d ON d.id=a.document_id -WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID) +WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, + func() string { + if kind == domain.StarGiftCollectibleModel { + return "a.crafted" + } + return "false" + }(), table), revisionID) if err != nil { return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err) } @@ -199,7 +255,8 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio for rows.Next() { attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}} var attrsJSON, thumbsJSON, sourceFormat string - if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder, + if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind, + &attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder, &attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat, &attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint, &attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date, @@ -221,7 +278,7 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) { rows, err := db.Query(ctx, ` SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, - text_color, rarity_permille, sort_order + text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID) if err != nil { return nil, fmt.Errorf("list collectible backdrops: %w", err) @@ -232,7 +289,7 @@ FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY s attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop} if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID, &attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor, - &attribute.RarityPermille, &attribute.SortOrder); err != nil { + &attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil { return nil, err } out = append(out, attribute) @@ -292,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) return out, nil } +func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if owner.ID <= 0 || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + if limit > domain.MaxSavedStarGiftsLimit { + limit = domain.MaxSavedStarGiftsLimit + } + rows, err := s.db.Query(ctx, uniqueStarGiftQuery(` +u.owner_peer_type=$1 AND u.owner_peer_id=$2 +AND NOT u.burned AND u.owner_address='' +AND sg.lifecycle_status='active'`)+` +ORDER BY u.id DESC +LIMIT $3`, string(owner.Type), owner.ID, limit) + if err != nil { + return nil, fmt.Errorf("list unique star gifts by owner: %w", err) + } + defer rows.Close() + out := make([]domain.UniqueStarGift, 0, limit) + for rows.Next() { + gift, err := scanUniqueStarGift(rows) + if err != nil { + return nil, err + } + out = append(out, gift) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err) + } + return out, nil +} + func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) { row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value) unique, err := scanUniqueStarGift(row) @@ -307,14 +395,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, func uniqueStarGiftQuery(predicate string) string { return fmt.Sprintf(` SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num, - u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at, - r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id, + COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at, + u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted, + u.owner_name, u.owner_address, u.gift_address, + COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0), + COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0), + u.value_amount, u.value_currency, u.value_usd_amount, + COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0), + COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0), + u.offer_min_stars, u.craft_chance_permille, u.last_sale_date, + u.last_sale_currency, u.last_sale_amount, + r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id, sg.gift_date, sg.message, sg.name_hidden, - m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date, + m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date, md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text, - p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date, + p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date, pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text, - b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille + b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, + b.rarity_kind, COALESCE(b.rarity_permille,0) FROM unique_star_gifts u JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id @@ -323,12 +421,14 @@ JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id JOIN documents pd ON pd.id=p.document_id JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id +LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id WHERE %s`, predicate) } func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) { var unique domain.UniqueStarGift - var ownerType, originalOwnerType string + var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string + var listingAmount, lastSaleAmount int64 unique.Model.Kind = domain.StarGiftCollectibleModel unique.Pattern.Kind = domain.StarGiftCollectiblePattern unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop @@ -337,23 +437,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) { var modelAttrs, modelThumbs, patternAttrs, patternThumbs string if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID, &unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails, - &unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal, + &unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable, + &unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress, + &listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID, + &unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD, + &themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID, + &unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate, + &lastSaleCurrency, &lastSaleAmount, + &unique.AvailabilityIssued, &unique.AvailabilityTotal, &unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate, &unique.OriginalMessage, &unique.OriginalNameHidden, - &unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille, + &unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted, &unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference, &unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size, &unique.Model.Document.DCID, &modelAttrs, &modelThumbs, - &unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille, + &unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille, &unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference, &unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size, &unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs, &unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor, - &unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil { + &unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, + &unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil { return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err) } unique.Owner.Type = domain.PeerType(ownerType) unique.OriginalOwner.Type = domain.PeerType(originalOwnerType) + unique.ReleasedBy.Type = domain.PeerType(releasedByType) + unique.ThemePeer.Type = domain.PeerType(themePeerType) + unique.Host.Type = domain.PeerType(hostPeerType) + if listingCurrency != "" && listingAmount > 0 { + unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount} + } + if lastSaleCurrency != "" && unique.LastSaleDate > 0 { + unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount} + } unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID @@ -603,7 +720,7 @@ func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, own } rows, err := db.Query(ctx, ` SELECT id FROM peer_star_gifts -WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[]) +WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[]) FOR UPDATE`, string(owner.Type), owner.ID, ids) if err != nil { return nil, err diff --git a/internal/store/postgres/star_gift_collectibles_integration_test.go b/internal/store/postgres/star_gift_collectibles_integration_test.go index 72b242b9..1a9d6288 100644 --- a/internal/store/postgres/star_gift_collectibles_integration_test.go +++ b/internal/store/postgres/star_gift_collectibles_integration_test.go @@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix, Models: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000, + Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 922, Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"), + OfficialDocumentID: 5100000000000000001, + }, { + Kind: domain.StarGiftCollectibleModel, Name: "Crafted Aurora", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"), + OfficialDocumentID: 5100000000000000003, }}, Patterns: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000, - Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"), + Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"), }}, Backdrops: []domain.StarGiftCollectibleAttribute{{ Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, - RarityPermille: 1000, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999, }}, Actor: "integration", CommandID: "collectibles-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32), }) if err != nil { t.Fatalf("publish collectible pool: %v", err) } - if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 { + if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 || + !poolRevision.Models[1].Crafted || poolRevision.Models[1].RarityKind != domain.StarGiftRarityLegendary || + poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 { t.Fatalf("published pool = %+v", poolRevision) } availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1}) @@ -74,21 +83,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err) } - savedID, err := gifts.Create(ctx, domain.SavedStarGift{ + messages := NewMessageStore(pool) + saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original", + Date: 1700001000, ConvertStars: 25, Message: "original", }) - if err != nil { - t.Fatalf("create saved gift: %v", err) - } + savedID := saved.ID stars := NewStarsStore(pool) if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil { t.Fatalf("grant upgrade stars: %v", err) } - messages := NewMessageStore(pool) upgrades := NewStarGiftUpgradeStore(pool, messages) req := domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001}, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID}, KeepOriginalDetails: true, ChargeStars: 100, FormID: 991, CommandKey: "paid-" + suffix, Date: 1700001002, } @@ -108,6 +115,31 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID { t.Fatalf("owner upgrade service message = %+v", ownerMessage) } + uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique + if uniqueAction.SavedID != int64(saved.MsgID) { + t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID) + } + ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) + if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil || + ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade { + t.Fatalf("owner source gift was not durably marked upgraded: %+v", ownerSourceEdit) + } + senderSourceEdit := upgradedSourceEditForUser(upgraded, sender.ID) + if senderSourceEdit.Message.Media == nil || senderSourceEdit.Message.Media.ServiceAction == nil || + senderSourceEdit.Message.Media.ServiceAction.StarGift == nil || + senderSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.SenderMessage.ID { + t.Fatalf("sender source gift has wrong box-local upgrade link: %+v", senderSourceEdit) + } + difference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, ownerMessage.Pts-1, 4) + if err != nil || len(difference) < 2 || difference[0].Type != domain.UpdateEventNewMessage || + difference[0].Message.ID != ownerMessage.ID || difference[1].Type != domain.UpdateEventEditMessage || + difference[1].Message.ID != saved.MsgID || difference[1].Message.Media == nil || + difference[1].Message.Media.ServiceAction == nil || difference[1].Message.Media.ServiceAction.StarGift == nil || + difference[1].Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID { + t.Fatalf("owner upgrade difference = %+v err %v", difference, err) + } var ( issued, uniqueCount, commandCount int @@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) { t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason) } + receipt, found, err := upgrades.StarGiftUpgradeReceipt(ctx, owner.ID, req.CommandKey) + if err != nil || !found || receipt.SourceSavedGiftID != savedID || receipt.UniqueGiftID != upgraded.Unique.ID || + receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid || + !receipt.KeepOriginalDetails || receipt.BalanceAfter != 900 || receipt.SourceEditPts != ownerSourceEdit.Event.Pts { + t.Fatalf("upgrade receipt = %+v found=%v err=%v", receipt, found, err) + } replayed, err := upgrades.UpgradeStarGift(ctx, req) if err != nil { t.Fatalf("replay upgrade: %v", err) } - if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 { + if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 || + upgradedSourceEditForUser(replayed, owner.ID).Event.Pts != ownerSourceEdit.Event.Pts { t.Fatalf("replayed upgrade = %+v", replayed) } conflictingReplay := req @@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("balance after retries = %+v err %v", bal, err) } - prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{ + prepaidSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, // A later pool revision may raise the current price; the historical paid // amount remains an entitlement instead of being compared to that price. - MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50, + Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50, }) - if err != nil { - t.Fatalf("create prepaid saved gift: %v", err) - } + prepaidSavedID := prepaidSaved.ID prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002}, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID}, RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005, }) if err != nil { @@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("prepaid upgrade = %+v", prepaid) } - insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{ + insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700003, Date: 1700001006, ConvertStars: 25, + Date: 1700001006, ConvertStars: 25, }) - if err != nil { - t.Fatalf("create insufficient saved gift: %v", err) - } + insufficientSavedID := insufficientSaved.ID if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction, domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil { t.Fatalf("seed isolated paid reaction debit: %v", err) } if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}, - ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008, + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}, + ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008, }); !errors.Is(err, domain.ErrStarsInsufficient) { t.Fatalf("insufficient upgrade err = %v", err) } - insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}) - if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 { - t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err) + insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}) + if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 { + t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientAfter, found, err) } if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 { t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err) @@ -220,12 +255,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "") concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID} - if _, err := gifts.Create(ctx, domain.SavedStarGift{ + concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, - MsgID: 700004, Date: 1700001010, ConvertStars: 25, - }); err != nil { - t.Fatalf("create concurrent upgrade target: %v", err) - } + Date: 1700001010, ConvertStars: 25, + }) if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil { t.Fatalf("grant concurrent balance: %v", err) } @@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { go func() { <-start _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004}, + UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: concurrentSaved.MsgID}, ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012, }) results <- concurrentDebitResult{kind: "gift_upgrade", err: err} @@ -302,19 +335,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix, Models: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityPermille: 1000, + Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"), }}, Patterns: []domain.StarGiftCollectibleAttribute{{ - Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000, - Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), + Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"), }}, Backdrops: []domain.StarGiftCollectibleAttribute{{ Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, - RarityPermille: 1000, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, }}, Actor: "integration", CommandID: "soldout-pool-" + suffix, }) @@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { } soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "") soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID} - for index, msgID := range []int{700010, 700011} { - if _, err := gifts.Create(ctx, domain.SavedStarGift{ + soldOutSaved := make([]domain.SavedStarGift, 0, 2) + for index := range 2 { + soldOutSaved = append(soldOutSaved, createCollectibleSavedGift(t, ctx, messages, gifts, soldOutEntry.Gift, domain.SavedStarGift{ Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID, - MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10, - }); err != nil { - t.Fatalf("create sold-out target %d: %v", msgID, err) - } + Date: 1700001020 + index, ConvertStars: 10, + })) } if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil { t.Fatalf("grant sold-out owner balance: %v", err) } if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010}, - ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023, + UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID}, + ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023, }); err != nil { t.Fatalf("fill collectible supply: %v", err) } balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID) if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ - UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011}, - ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024, + UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID}, + ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024, }); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) { t.Fatalf("sold-out upgrade err = %v", err) } @@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { if err != nil { t.Fatalf("create ordinary collection: %v", err) } - converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003}) + converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID}) if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 { t.Fatalf("convert collection member = %+v err %v", converted, err) } @@ -386,6 +418,106 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { } } +func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1779"+suffix+"51", "NoCraftSender", "") + owner := createTestUser(t, ctx, users, "+1779"+suffix+"52", "NoCraftOwner", "") + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + gifts := NewStarGiftStore(pool) + baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "No Craft " + suffix, Stars: 50, ConvertStars: 25, Enabled: true, + Document: collectibleTestDocument(baseDocumentID, "no-craft-gift.tgs"), + Blob: collectibleTestBlob(baseDocumentID, "no-craft-gift"), Animation: collectibleTestAnimation("no-craft-gift.tgs"), + Actor: "integration", CommandID: "no-craft-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create no-craft catalog gift: %v", err) + } + revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "no-craft-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs"), + }}, + Patterns: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"), + Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs"), + }}, + Backdrops: []domain.StarGiftCollectibleAttribute{{ + Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + }}, + Actor: "integration", CommandID: "no-craft-pool-" + suffix, + }) + if err != nil { + t.Fatalf("publish no-craft pool: %v", err) + } + if len(revision.Models) != 1 || revision.Models[0].Crafted { + t.Fatalf("no-craft pool models = %+v", revision.Models) + } + + messages := NewMessageStore(pool) + saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{ + Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, + Date: now, ConvertStars: 25, + }) + stars := NewStarsStore(pool) + if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, now); err != nil { + t.Fatalf("grant no-craft upgrade stars: %v", err) + } + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 750, + })) + upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID}, + ChargeStars: 100, FormID: 551, CommandKey: "no-craft-upgrade-" + suffix, Date: now + 1, + }) + if err != nil { + t.Fatalf("upgrade no-craft gift: %v", err) + } + uniqueAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + if upgraded.Unique.CraftChancePermille != 0 || upgraded.Saved.CanCraftAt != 0 || + uniqueAction == nil || uniqueAction.Gift.CraftChancePermille != 0 || uniqueAction.CanCraftAt != 0 { + t.Fatalf("no-craft capability leaked: saved=%+v unique=%+v action=%+v", upgraded.Saved, upgraded.Unique, uniqueAction) + } + + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000) + page, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 10) + if err != nil || page.Count != 0 || len(page.Gifts) != 0 { + t.Fatalf("no-craft candidate page = %+v err %v", page, err) + } + if _, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{ + UserID: owner.ID, Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: saved.MsgID}}, + CommandKey: "no-craft-attempt-" + suffix, Date: now + 2, + }); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { + t.Fatalf("no-craft attempt err = %v", err) + } + var lifecycleStatus string + var burned bool + var commandCount int + if err := pool.QueryRow(ctx, `SELECT p.lifecycle_status,u.burned +FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.id=$1`, upgraded.Saved.ID). + Scan(&lifecycleStatus, &burned); err != nil { + t.Fatalf("load no-craft aggregate: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, + owner.ID, "no-craft-attempt-"+suffix).Scan(&commandCount); err != nil { + t.Fatalf("count no-craft commands: %v", err) + } + if lifecycleStatus != "active" || burned || commandCount != 0 { + t.Fatalf("no-craft attempt mutated aggregate: status=%q burned=%t commands=%d", lifecycleStatus, burned, commandCount) + } +} + func collectibleTestAnimation(name string) domain.StarGiftAnimation { return domain.StarGiftAnimation{ SourceName: name, SourceFormat: domain.StarGiftAnimationTGS, @@ -416,6 +548,13 @@ func collectibleTestDocumentPtr(id int64, name string) *domain.Document { return &document } +func collectibleTestPatternDocumentPtr(id int64, name string) *domain.Document { + document := collectibleTestDocument(id, name) + document.Attributes[1] = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true} + document.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}} + return &document +} + func collectibleTestBlob(id int64, suffix string) domain.FileBlob { return domain.FileBlob{ LocationKey: fmt.Sprintf("doc:%d", id), Backend: domain.MediaBackendLocalFS, @@ -427,3 +566,53 @@ func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob { blob := collectibleTestBlob(id, suffix) return &blob } + +// createCollectibleSavedGift seeds the same valid source-message + saved-gift +// invariant as the purchase aggregate. Tests must not invent a peer_star_gifts +// msg_id that has no durable message box behind it. +func createCollectibleSavedGift( + t *testing.T, + ctx context.Context, + messages *MessageStore, + gifts *StarGiftStore, + gift domain.StarGift, + saved domain.SavedStarGift, +) domain.SavedStarGift { + t.Helper() + sticker := gift.Sticker + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: saved.FromUserID, + RecipientUserID: saved.Owner.ID, + RandomID: (time.Now().UnixNano() & 0x7fffffffffffffff) ^ saved.Owner.ID ^ int64(saved.Date), + Date: saved.Date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, + StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, + Title: gift.Title, Sticker: &sticker, Message: saved.Message, + FromUserID: saved.FromUserID, PeerUserID: saved.Owner.ID, Saved: true, + CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, + UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars, + }, + }}, + }) + if err != nil { + t.Fatalf("create collectible source message: %v", err) + } + saved.MsgID = sent.RecipientMessage.ID + id, err := gifts.Create(ctx, saved) + if err != nil { + t.Fatalf("create saved gift: %v", err) + } + saved.ID = id + return saved +} + +func upgradedSourceEditForUser(result domain.StarGiftUpgradeResult, userID int64) domain.EditedMessageForUser { + for _, edit := range result.SourceEdits { + if edit.UserID == userID { + return edit + } + } + return domain.EditedMessageForUser{UserID: userID} +} diff --git a/internal/store/postgres/star_gift_craft_auction.go b/internal/store/postgres/star_gift_craft_auction.go new file mode 100644 index 00000000..229ba392 --- /dev/null +++ b/internal/store/postgres/star_gift_craft_auction.go @@ -0,0 +1,1094 @@ +package postgres + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +const ( + starGiftAuctionRoundDuration = 3600 + maxStarGiftAuctionAcquired = 1000 +) + +func defaultStarGiftCraftDraw(upper int) (int, error) { + if upper <= 0 { + return 0, domain.ErrStarGiftCraftUnavailable + } + draw, err := rand.Int(rand.Reader, big.NewInt(int64(upper))) + if err != nil { + return 0, err + } + return int(draw.Int64()), nil +} + +// SweepStarGiftLifecycle advances time-driven aggregates without requiring a +// foreground client RPC. All effects remain local PostgreSQL ledger/message +// mutations; this worker never talks to TON, Fragment, wallets or chain nodes. +func (s *StarGiftLifecycleStore) SweepStarGiftLifecycle(ctx context.Context, now, limit int) error { + if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 { + return domain.ErrStarGiftUnavailable + } + if limit > 10000 { + limit = 10000 + } + // Payment forms are short-lived intents, not permanent receipts. Committed + // purchases replay from star_gift_purchase_commands/private-send receipts, + // so expired form rows can be removed independently in a bounded batch. + formLimit := limit + if formLimit > 1000 { + formLimit = 1000 + } + if _, err := s.db.Exec(ctx, `WITH stale AS ( +SELECT buyer_user_id,form_id FROM star_gift_purchase_forms +WHERE expires_at<$1 ORDER BY expires_at,buyer_user_id,form_id +FOR UPDATE SKIP LOCKED LIMIT $2) +DELETE FROM star_gift_purchase_forms f USING stale +WHERE f.buyer_user_id=stale.buyer_user_id AND f.form_id=stale.form_id`, now, formLimit); err != nil { + return err + } + remaining := limit + for remaining > 0 { + batch := remaining + if batch > 100 { + batch = 100 + } + count, err := s.expireStarGiftOffersBatch(ctx, now, batch) + if err != nil { + return err + } + remaining -= count + if count < batch { + break + } + } + for remaining > 0 { + batch := remaining + if batch > 100 { + batch = 100 + } + count, err := s.dispatchStarGiftOfferResolutions(ctx, batch) + if err != nil { + return err + } + remaining -= count + if count < batch { + break + } + } + + auctionLimit := remaining + if auctionLimit > 100 { + auctionLimit = 100 + } + if auctionLimit > 0 { + rows, err := s.db.Query(ctx, `SELECT gift_id FROM star_gift_auctions +WHERE (status='pending' AND start_date<=$1) OR + (status='active' AND (next_round_at<=$1 OR end_date<=$1)) +ORDER BY next_round_at,gift_id LIMIT $2`, now, auctionLimit) + if err != nil { + return err + } + giftIDs := make([]int64, 0, auctionLimit) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + rows.Close() + return err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, giftID := range giftIDs { + if err := s.settleStarGiftAuction(ctx, giftID, now); err != nil { + return err + } + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return err + } + } + remaining -= len(giftIDs) + } + + // A prior process can commit award rows and stop before delivery. Drain those + // rows even if their auction clock is no longer due. + if remaining > 0 { + rows, err := s.db.Query(ctx, `SELECT DISTINCT gift_id FROM star_gift_auction_acquired +WHERE saved_gift_id IS NULL ORDER BY gift_id LIMIT $1`, minAuctionInt(remaining, 100)) + if err != nil { + return err + } + giftIDs := make([]int64, 0) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + rows.Close() + return err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, giftID := range giftIDs { + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return err + } + } + } + return nil +} + +func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) { + if s == nil || s.db == nil || userID <= 0 || giftID <= 0 || limit <= 0 || limit > domain.MaxSavedStarGiftsLimit || len(offset) > domain.MaxStarGiftsOffsetBytes { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + args := []any{userID, giftID} + where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2 + AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer + AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0 + AND EXISTS (SELECT 1 FROM star_gift_collectible_models m + WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)` + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE `+where, args...).Scan(&total); err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("count craft star gifts: %w", err) + } + if cursor, ok := domain.DecodeStarGiftCursor(offset); ok { + args = append(args, cursor) + where += fmt.Sprintf(" AND p.id<$%d", len(args)) + } else if offset != "" { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + args = append(args, limit+1) + rows, err := s.db.Query(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE `+where+` +ORDER BY p.id DESC LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("list craft star gifts: %w", err) + } + defer rows.Close() + gifts := make([]domain.SavedStarGift, 0, limit+1) + uniqueIDs := make([]int64, 0, limit+1) + for rows.Next() { + gift, scanErr := scanSavedStarGift(rows) + if scanErr != nil { + return domain.SavedStarGiftPage{}, scanErr + } + gifts = append(gifts, gift) + uniqueIDs = append(uniqueIDs, gift.UniqueGiftID) + } + if err := rows.Err(); err != nil { + return domain.SavedStarGiftPage{}, err + } + hasMore := len(gifts) > limit + if hasMore { + gifts, uniqueIDs = gifts[:limit], uniqueIDs[:limit] + } + uniqueByID, err := NewStarGiftStore(s.db).UniqueByIDs(ctx, uniqueIDs) + if err != nil { + return domain.SavedStarGiftPage{}, err + } + for i := range gifts { + unique, ok := uniqueByID[gifts[i].UniqueGiftID] + if !ok { + return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable + } + gifts[i].Unique = &unique + } + page := domain.SavedStarGiftPage{Count: total, Gifts: gifts} + if hasMore && len(gifts) > 0 { + page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID) + } + return page, nil +} + +func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) { + if s == nil || s.db == nil || s.messages == nil || s.craftDraw == nil || req.UserID <= 0 || len(req.Refs) < 1 || len(req.Refs) > 4 || req.Date <= 0 || + strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for _, ref := range req.Refs { + if !ref.Valid() || ref.Owner != owner { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + } + // A committed failed craft has already moved every input out of the active + // lifecycle. Consult the immutable receipt before active-gift resolution so + // an exact transport retry can still replay the same terminal result. + if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found { + if err != nil || !replay.Success { + return replay, err + } + return s.deliverCraftSuccess(ctx, req, replay) + } + savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs) + if err != nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + if len(sortedUniqueInt64(savedIDs)) != len(savedIDs) { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + var result domain.StarGiftCraftResult + var resultUniqueID int64 + err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error { + lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs)) + if err != nil { + return err + } + locked := 0 + for lockedRows.Next() { + locked++ + } + lockedRows.Close() + if locked != len(savedIDs) { + return domain.ErrStarGiftCraftUnavailable + } + + savedByID := make(map[int64]domain.SavedStarGift, len(savedIDs)) + uniqueIDs := make([]int64, 0, len(savedIDs)) + var giftID, revisionID int64 + chance := 0 + for i := range req.Refs { + saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i]) + if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date { + return domain.ErrStarGiftCraftUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found || unique.Owner != owner || unique.Burned || unique.OwnerAddress != "" || unique.CraftChancePermille <= 0 { + return domain.ErrStarGiftCraftUnavailable + } + if giftID == 0 { + giftID, revisionID = unique.GiftID, unique.CollectibleRevisionID + } else if unique.GiftID != giftID || unique.CollectibleRevisionID != revisionID { + return domain.ErrStarGiftCraftUnavailable + } + savedByID[saved.ID] = saved + uniqueIDs = append(uniqueIDs, unique.ID) + chance += unique.CraftChancePermille + } + if chance > 1000 { + chance = 1000 + } + var craftable bool + if err := tx.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM star_gift_collectible_models +WHERE collectible_revision_id=$1 AND crafted +)`, revisionID).Scan(&craftable); err != nil { + return err + } + if !craftable { + return domain.ErrStarGiftCraftUnavailable + } + draw, err := s.craftDraw(1000) + if err != nil { + return fmt.Errorf("draw star gift craft outcome: %w", err) + } + result.Chance = chance + result.Success = draw < chance + + if _, err := tx.Exec(ctx, `SELECT id FROM unique_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(uniqueIDs)); err != nil { + return err + } + // TDesktop deliberately keeps Craft available after an owner lists a + // collectible. Consuming the gift therefore closes every market claim in + // the same transaction: pending buyers are refunded before their offers + // are cancelled, listings disappear, and the catalog resale projection is + // refreshed before any input is crafted or burned. + for _, uniqueID := range uniqueIDs { + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, req.Date, "gift crafted"); err != nil { + return err + } + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, uniqueIDs); err != nil { + return err + } + if err := updateStarGiftResaleProjection(ctx, tx, giftID); err != nil { + return err + } + for _, savedID := range savedIDs { + saved := savedByID[savedID] + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + } + + firstSavedID, firstUniqueID := savedIDs[0], uniqueIDs[0] + if result.Success { + modelID, err := chooseCraftedModel(ctx, tx, revisionID) + if err != nil { + return err + } + patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revisionID) + if err != nil { + return err + } + backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revisionID) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET model_attribute_id=$2,pattern_attribute_id=$3, +backdrop_attribute_id=$4,crafted=true,craft_chance_permille=0,updated_at=now() WHERE id=$1`, firstUniqueID, modelID, patternID, backdropID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, firstSavedID); err != nil { + return err + } + } + burnFrom := 0 + if result.Success { + burnFrom = 1 + } + if burnFrom < len(uniqueIDs) { + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET burned=true,craft_chance_permille=0, +offer_min_stars=0,updated_at=now() WHERE id=ANY($1::bigint[])`, uniqueIDs[burnFrom:]); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='burned',unsaved=true,pinned_order=0, +transfer_stars=0,can_export_at=0,can_transfer_at=0,can_resell_at=0,drop_original_details_stars=0,can_craft_at=0 +WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil { + return err + } + } + sourceEdits, sourceEditPTS, err := s.markCraftInputMessagesTx(ctx, tx, req, savedIDs) + if err != nil { + return err + } + result.SourceEdits = sourceEdits + var resultID any + if result.Success { + resultID = firstUniqueID + resultUniqueID = firstUniqueID + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id, +success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, + strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS) + return err + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftCraftResult{}, err + } + if result.Success { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID) + if err != nil || !found { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + result.Gift = &gift + } + if result.Success { + return s.deliverCraftSuccess(ctx, req, result) + } + return result, nil +} + +func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) { + if result.Gift == nil || s.messages == nil { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } + sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID, + RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true, + CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, + CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, + CanCraftAt: saved.CanCraftAt}}}}) + if err != nil { + return domain.StarGiftCraftResult{}, err + } + result.Send = sent + result.Duplicate = result.Duplicate || sent.Duplicate + return result, nil +} + +func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) { + var success bool + var resultID *int64 + var chance int + var inputUniqueIDs []int64 + var sourceEditPTS []int32 + err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts +FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, + req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftCraftResult{}, false, nil + } + if err != nil { + return domain.StarGiftCraftResult{}, false, err + } + if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + savedIDs := make([]int64, 0, len(inputUniqueIDs)) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for i, uniqueID := range inputUniqueIDs { + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) + if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + ref := req.Refs[i] + if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + if ref.Slug != "" { + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + } + savedIDs = append(savedIDs, saved.ID) + } + sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS) + if err != nil { + return domain.StarGiftCraftResult{}, false, err + } + result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true} + if resultID != nil { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID) + if err != nil || !found { + return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + } + result.Gift = &gift + } + return result, true, nil +} + +func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) { + var count int64 + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted`, revisionID).Scan(&count); err != nil { + return 0, err + } + if count == 0 { + return 0, domain.ErrStarGiftCraftUnavailable + } + draw, err := rand.Int(rand.Reader, big.NewInt(count)) + if err != nil { + return 0, err + } + var id int64 + if err := tx.QueryRow(ctx, `SELECT id FROM star_gift_collectible_models WHERE collectible_revision_id=$1 AND crafted ORDER BY sort_order,id OFFSET $2 LIMIT 1`, revisionID, draw.Int64()).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +// settleStarGiftAuction lazily advances every elapsed round. Auction rows are +// the clock aggregate; acquired rows are a durable delivery outbox. A winner's +// reserved bid is consumed, while bids that can no longer reach any remaining +// gift are refunded atomically with the state transition. +func (s *StarGiftLifecycleStore) settleStarGiftAuction(ctx context.Context, giftID int64, now int) error { + if giftID <= 0 || now <= 0 { + return domain.ErrStarGiftAuctionUnavailable + } + return withTx(ctx, s.db, "settle star gift auction", func(tx pgx.Tx) error { + var startDate, endDate, roundDuration, giftsPerRound, totalRounds, currentRound, nextRoundAt, lastGiftNum, giftsLeft int + var status string + if err := tx.QueryRow(ctx, `SELECT start_date,end_date,round_duration,gifts_per_round,total_rounds,current_round, +next_round_at,last_gift_num,gifts_left,status FROM star_gift_auctions WHERE gift_id=$1 FOR UPDATE`, giftID). + Scan(&startDate, &endDate, &roundDuration, &giftsPerRound, &totalRounds, ¤tRound, + &nextRoundAt, &lastGiftNum, &giftsLeft, &status); err != nil { + return err + } + if status == "cancelled" || status == "completed" { + return nil + } + if now < startDate { + return nil + } + changed := false + if status == "pending" { + status = "active" + changed = true + if currentRound == 0 { + currentRound = 1 + } + } + awardedCount := 0 + for status == "active" && currentRound <= totalRounds && nextRoundAt <= now { + awardLimit := giftsPerRound + if awardLimit > giftsLeft { + awardLimit = giftsLeft + } + type winner struct { + userID, recipientID, amount int64 + recipientType string + bidDate int + hide bool + message string + } + winners := make([]winner, 0, awardLimit) + if awardLimit > 0 { + rows, err := tx.Query(ctx, `SELECT bidder_user_id,recipient_peer_type,recipient_peer_id,amount,bid_date,hide_name,message +FROM star_gift_auction_bids WHERE gift_id=$1 AND active ORDER BY amount DESC,bid_date,bidder_user_id LIMIT $2 FOR UPDATE`, giftID, awardLimit) + if err != nil { + return err + } + for rows.Next() { + var winner winner + if err := rows.Scan(&winner.userID, &winner.recipientType, &winner.recipientID, &winner.amount, + &winner.bidDate, &winner.hide, &winner.message); err != nil { + rows.Close() + return err + } + winners = append(winners, winner) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + if len(winners) == 0 { + // No active bid can produce an award in any elapsed round. Fast-forward + // the clock aggregate instead of looping once per (possibly very large) + // official supply round after a long process outage. + through := now + if through > endDate { + through = endDate + } + dueRounds := (through-nextRoundAt)/roundDuration + 1 + remainingRounds := totalRounds - currentRound + 1 + if dueRounds > remainingRounds { + dueRounds = remainingRounds + } + if dueRounds < 1 { + dueRounds = 1 + } + currentRound += dueRounds + nextRoundAt += dueRounds * roundDuration + changed = true + if currentRound > totalRounds || nextRoundAt > endDate { + status = "completed" + } + continue + } + for pos, winner := range winners { + giftNum := lastGiftNum + pos + 1 + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_acquired(gift_id,bidder_user_id,recipient_peer_type, +recipient_peer_id,bid_amount,round,pos,gift_num,acquired_at,hide_name,message) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(gift_id,round,pos) DO NOTHING`, + giftID, winner.userID, winner.recipientType, winner.recipientID, winner.amount, currentRound, pos+1, + giftNum, nextRoundAt, winner.hide, winner.message); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active=false,returned=false, +acquired_count=acquired_count+1,version=version+1 WHERE gift_id=$1 AND bidder_user_id=$2 AND active`, giftID, winner.userID); err != nil { + return err + } + } + lastGiftNum += len(winners) + giftsLeft -= len(winners) + awardedCount += len(winners) + currentRound++ + nextRoundAt += roundDuration + changed = true + if currentRound > totalRounds || giftsLeft <= 0 || nextRoundAt > endDate { + status = "completed" + } + } + if status == "active" && now >= endDate { + status = "completed" + changed = true + } + // Any active rank beyond all remaining gifts can never win, even after + // higher bids are consumed in later rounds, and is therefore refundable. + refundAll := status == "completed" || giftsLeft <= 0 + if err := s.refundUnreachableAuctionBids(ctx, tx, giftID, giftsLeft, refundAll, now); err != nil { + return err + } + if changed { + if _, err := tx.Exec(ctx, `UPDATE star_gift_auctions SET status=$2,current_round=$3,next_round_at=$4, +last_gift_num=$5,gifts_left=$6,version=version+1,updated_at=now() WHERE gift_id=$1`, giftID, status, + minAuctionInt(currentRound, totalRounds), minAuctionInt(nextRoundAt, endDate), lastGiftNum, giftsLeft); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=$2,last_sale_date=CASE WHEN $3>0 THEN $4 ELSE last_sale_date END, +first_sale_date=CASE WHEN first_sale_date=0 AND $3>0 THEN $4 ELSE first_sale_date END,updated_at=now() WHERE gift_id=$1`, + giftID, giftsLeft, awardedCount, now); err != nil { + return err + } + } + return nil + }) +} + +func (s *StarGiftLifecycleStore) refundUnreachableAuctionBids(ctx context.Context, tx pgx.Tx, giftID int64, giftsLeft int, all bool, date int) error { + offset := giftsLeft + if all { + offset = 0 + } + rows, err := tx.Query(ctx, `SELECT bidder_user_id,recipient_peer_type,recipient_peer_id,amount FROM star_gift_auction_bids +WHERE gift_id=$1 AND active ORDER BY amount DESC,bid_date,bidder_user_id OFFSET $2 FOR UPDATE`, giftID, offset) + if err != nil { + return err + } + type refundable struct { + userID, peerID, amount int64 + peerType string + } + items := make([]refundable, 0) + for rows.Next() { + var item refundable + if err := rows.Scan(&item.userID, &item.peerType, &item.peerID, &item.amount); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, item := range items { + if err := s.creditLifecycleAmount(ctx, tx, item.userID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: item.amount}, + domain.StarsReasonGiftAuction, domain.Peer{Type: domain.PeerType(item.peerType), ID: item.peerID}, date, + "Star gift auction bid refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active=false,returned=true,version=version+1 +WHERE gift_id=$1 AND bidder_user_id=$2 AND active`, giftID, item.userID); err != nil { + return err + } + } + return nil +} + +func minAuctionInt(a, b int) int { + if a < b { + return a + } + return b +} + +func (s *StarGiftLifecycleStore) dispatchStarGiftAuctionAwards(ctx context.Context, giftID int64) error { + if s.messages == nil { + return domain.ErrStarGiftAuctionUnavailable + } + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found { + return domain.ErrStarGiftAuctionUnavailable + } + dispatched := 0 + for dispatched < maxStarGiftAuctionAcquired { + rows, err := s.db.Query(ctx, `SELECT id,bidder_user_id,recipient_peer_type,recipient_peer_id,bid_amount, +round,pos,COALESCE(gift_num,0),acquired_at,hide_name,message FROM star_gift_auction_acquired +WHERE gift_id=$1 AND saved_gift_id IS NULL ORDER BY id LIMIT 100`, giftID) + if err != nil { + return err + } + items := make([]struct { + id, bidder, recipientID, amount int64 + recipientType string + round, pos, giftNum, date int + hide bool + message string + }, 0) + for rows.Next() { + var item struct { + id, bidder, recipientID, amount int64 + recipientType string + round, pos, giftNum, date int + hide bool + message string + } + if err := rows.Scan(&item.id, &item.bidder, &item.recipientType, &item.recipientID, &item.amount, + &item.round, &item.pos, &item.giftNum, &item.date, &item.hide, &item.message); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + if len(items) == 0 { + return nil + } + for _, item := range items { + owner := domain.Peer{Type: domain.PeerType(item.recipientType), ID: item.recipientID} + var msgID int + if owner.Type == domain.PeerTypeUser { + sticker := gift.Sticker + sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: item.bidder, + RecipientUserID: owner.ID, RandomID: lifecycleCommandRandomID("auction-award", giftID, item.round, item.pos), + Date: item.date, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID, + Stars: gift.Stars, ConvertStars: 0, Title: gift.Title, Sticker: &sticker, Message: item.message, + FromUserID: item.bidder, PeerUserID: owner.ID, To: owner, NameHidden: item.hide, Saved: true, + AuctionAcquired: true, GiftNum: item.giftNum}}}}) + if err != nil { + return err + } + msgID = sent.RecipientMessage.ID + if msgID <= 0 { + msgID = sent.SenderMessage.ID + } + } + if err := withTx(ctx, s.db, "save star gift auction award", func(tx pgx.Tx) error { + var savedID *int64 + if err := tx.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE id=$1 FOR UPDATE`, item.id).Scan(&savedID); err != nil { + return err + } + if savedID != nil { + return nil + } + id, err := NewStarGiftStore(tx).Create(ctx, domain.SavedStarGift{Owner: owner, FromUserID: item.bidder, + GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: msgID, Date: item.date, NameHidden: item.hide, + ConvertStars: 0, Message: item.message, GiftNum: item.giftNum}) + if err != nil { + return err + } + if owner.Type == domain.PeerTypeChannel { + sticker := gift.Sticker + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: 0, Title: gift.Title, Sticker: &sticker, + Message: item.message, FromUserID: item.bidder, PeerChannelID: owner.ID, SavedID: id, + NameHidden: item.hide, Saved: true, AuctionAcquired: true, GiftNum: item.giftNum, + }} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, owner.ID, item.bidder, id, item.date, action); err != nil { + return err + } + } + _, err = tx.Exec(ctx, `UPDATE star_gift_auction_acquired SET saved_gift_id=$2 WHERE id=$1`, item.id, id) + return err + }); err != nil { + return err + } + dispatched++ + } + } + return nil +} + +func (s *StarGiftLifecycleStore) StarGiftAuctionState(ctx context.Context, userID int64, giftID int64, slug string, now int) (domain.StarGiftAuction, error) { + if s == nil || s.db == nil || userID <= 0 || now <= 0 || giftID <= 0 && strings.TrimSpace(slug) == "" { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + resolvedGiftID, err := s.ensureStarGiftAuction(ctx, giftID, strings.TrimSpace(slug), now) + if err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.settleStarGiftAuction(ctx, resolvedGiftID, now); err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.dispatchStarGiftAuctionAwards(ctx, resolvedGiftID); err != nil { + return domain.StarGiftAuction{}, err + } + return s.loadStarGiftAuction(ctx, userID, resolvedGiftID) +} + +func (s *StarGiftLifecycleStore) ActiveStarGiftAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) { + if userID <= 0 || now <= 0 { + return nil, domain.ErrStarGiftAuctionUnavailable + } + rows, err := s.db.Query(ctx, `SELECT DISTINCT a.gift_id FROM star_gift_auctions a +JOIN star_gift_auction_bids b ON b.gift_id=a.gift_id +WHERE b.bidder_user_id=$1 AND a.end_date>$2 AND a.status<>'cancelled' ORDER BY a.gift_id`, userID, now) + if err != nil { + return nil, err + } + defer rows.Close() + giftIDs := make([]int64, 0) + for rows.Next() { + var giftID int64 + if err := rows.Scan(&giftID); err != nil { + return nil, err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.StarGiftAuction, 0) + for _, giftID := range giftIDs { + state, err := s.StarGiftAuctionState(ctx, userID, giftID, "", now) + if err != nil { + return nil, err + } + if !state.Finished && state.UserState.BidDate > 0 { + out = append(out, state) + } + } + return out, nil +} + +func (s *StarGiftLifecycleStore) StarGiftAuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) { + if userID <= 0 || giftID <= 0 { + return nil, domain.ErrStarGiftAuctionUnavailable + } + if err := s.dispatchStarGiftAuctionAwards(ctx, giftID); err != nil { + return nil, err + } + rows, err := s.db.Query(ctx, `SELECT recipient_peer_type,recipient_peer_id,acquired_at,bid_amount,round,pos,message, +COALESCE(gift_num,0),hide_name FROM star_gift_auction_acquired WHERE bidder_user_id=$1 AND gift_id=$2 ORDER BY id DESC LIMIT $3`, + userID, giftID, maxStarGiftAuctionAcquired) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]domain.StarGiftAuctionAcquired, 0) + for rows.Next() { + var item domain.StarGiftAuctionAcquired + var peerType string + if err := rows.Scan(&peerType, &item.Peer.ID, &item.Date, &item.BidAmount, &item.Round, &item.Pos, + &item.Message, &item.GiftNum, &item.NameHidden); err != nil { + return nil, err + } + item.Peer.Type = domain.PeerType(peerType) + out = append(out, item) + } + return out, rows.Err() +} + +func (s *StarGiftLifecycleStore) BidStarGiftAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) { + if s == nil || s.db == nil || req.UserID <= 0 || req.GiftID <= 0 || !validLifecyclePeer(req.Peer) || + req.BidAmount <= 0 || req.FormID == 0 || req.Date <= 0 || len([]rune(req.Message)) > 128 { + return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable + } + if _, err := s.ensureStarGiftAuction(ctx, req.GiftID, "", req.Date); err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + if err := s.settleStarGiftAuction(ctx, req.GiftID, req.Date); err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + if balance, found, err := s.loadAuctionBidReplay(ctx, req.UserID, req.FormID, req.GiftID); err != nil || found { + if err != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + state, stateErr := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + return state, balance, stateErr + } + var balance domain.StarsBalance + err := withTx(ctx, s.db, "bid star gift auction", func(tx pgx.Tx) error { + var startDate, endDate int + var minimum int64 + var status string + if err := tx.QueryRow(ctx, `SELECT start_date,end_date,min_bid_amount,status FROM star_gift_auctions WHERE gift_id=$1 FOR UPDATE`, req.GiftID). + Scan(&startDate, &endDate, &minimum, &status); err != nil { + return err + } + if status != "active" || req.Date < startDate || req.Date >= endDate || req.BidAmount < minimum { + return domain.ErrStarGiftAuctionUnavailable + } + var oldAmount int64 + var oldActive bool + err := tx.QueryRow(ctx, `SELECT amount,active FROM star_gift_auction_bids WHERE gift_id=$1 AND bidder_user_id=$2 FOR UPDATE`, req.GiftID, req.UserID).Scan(&oldAmount, &oldActive) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if oldActive && (!req.UpdateBid || req.BidAmount <= oldAmount) || !oldActive && req.UpdateBid { + return domain.ErrStarGiftAuctionUnavailable + } + reserved := int64(0) + if oldActive { + reserved = oldAmount + } + delta := req.BidAmount - reserved + balance, err = s.debitLifecycleAmount(ctx, tx, req.UserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: delta}, domain.StarsReasonGiftAuction, + req.Peer, req.Date, "Star gift auction bid") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_bids(gift_id,bidder_user_id,recipient_peer_type,recipient_peer_id, +amount,bid_date,hide_name,message) VALUES($1,$2,$3,$4,$5,$6,$7,$8) +ON CONFLICT(gift_id,bidder_user_id) DO UPDATE SET +recipient_peer_type=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.recipient_peer_type ELSE EXCLUDED.recipient_peer_type END, +recipient_peer_id=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.recipient_peer_id ELSE EXCLUDED.recipient_peer_id END, +amount=EXCLUDED.amount,bid_date=EXCLUDED.bid_date, +hide_name=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.hide_name ELSE EXCLUDED.hide_name END, +message=CASE WHEN star_gift_auction_bids.active THEN star_gift_auction_bids.message ELSE EXCLUDED.message END, +returned=false,active=true,version=star_gift_auction_bids.version+1`, + req.GiftID, req.UserID, string(req.Peer.Type), req.Peer.ID, req.BidAmount, req.Date, req.HideName, req.Message); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_auction_bid_payments(user_id,form_id,gift_id,bid_amount,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6)`, req.UserID, req.FormID, req.GiftID, req.BidAmount, balance.Balance, req.Date); err != nil { + return err + } + _, err = tx.Exec(ctx, `UPDATE star_gift_auctions SET version=version+1,updated_at=now() WHERE gift_id=$1`, req.GiftID) + return err + }) + if err != nil { + if isUniqueViolation(err) { + if replayBalance, found, replayErr := s.loadAuctionBidReplay(ctx, req.UserID, req.FormID, req.GiftID); replayErr != nil || found { + state, stateErr := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + if replayErr != nil { + return domain.StarGiftAuction{}, domain.StarsBalance{}, replayErr + } + return state, replayBalance, stateErr + } + } + return domain.StarGiftAuction{}, domain.StarsBalance{}, err + } + state, err := s.loadStarGiftAuction(ctx, req.UserID, req.GiftID) + return state, balance, err +} + +func (s *StarGiftLifecycleStore) ensureStarGiftAuction(ctx context.Context, giftID int64, slug string, now int) (int64, error) { + if giftID == 0 { + if err := s.db.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog_revisions WHERE auction AND auction_slug=$1 ORDER BY id DESC LIMIT 1`, slug).Scan(&giftID); err != nil { + return 0, domain.ErrStarGiftAuctionUnavailable + } + } + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found || !gift.Auction || gift.GiftsPerRound <= 0 || gift.AuctionSlug == "" { + return 0, domain.ErrStarGiftAuctionUnavailable + } + if slug != "" && slug != gift.AuctionSlug { + return 0, domain.ErrStarGiftAuctionUnavailable + } + supply := gift.AvailabilityTotal + if supply <= 0 { + supply = gift.UpgradeTotal + } + if supply <= 0 { + return 0, domain.ErrStarGiftAuctionUnavailable + } + start := gift.AuctionStartDate + if start <= 0 { + start = now + } + totalRounds := (supply + gift.GiftsPerRound - 1) / gift.GiftsPerRound + end := start + totalRounds*starGiftAuctionRoundDuration + status := "pending" + currentRound := 0 + if now >= start { + status, currentRound = "active", 1 + } + _, err = s.db.Exec(ctx, `INSERT INTO star_gift_auctions(gift_id,slug,start_date,end_date,round_duration,gifts_per_round, +total_rounds,current_round,next_round_at,gifts_left,min_bid_amount,status) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(gift_id) DO NOTHING`, gift.ID, gift.AuctionSlug, + start, end, starGiftAuctionRoundDuration, gift.GiftsPerRound, totalRounds, currentRound, + start+starGiftAuctionRoundDuration, supply, maxInt64(1, gift.Stars), status) + if err != nil { + return 0, err + } + return gift.ID, nil +} + +func (s *StarGiftLifecycleStore) loadStarGiftAuction(ctx context.Context, userID, giftID int64) (domain.StarGiftAuction, error) { + gift, found, err := NewStarGiftStore(s.db).CatalogGift(ctx, giftID) + if err != nil || !found { + return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable + } + out := domain.StarGiftAuction{Gift: gift} + var status string + if err := s.db.QueryRow(ctx, `SELECT version,start_date,end_date,min_bid_amount,next_round_at,last_gift_num,gifts_left, +current_round,total_rounds,round_duration,status FROM star_gift_auctions WHERE gift_id=$1`, giftID).Scan(&out.Version, &out.StartDate, + &out.EndDate, &out.MinBidAmount, &out.NextRoundAt, &out.LastGiftNum, &out.GiftsLeft, &out.CurrentRound, + &out.TotalRounds, &out.RoundDuration, &status); err != nil { + return domain.StarGiftAuction{}, err + } + out.Finished = status == "completed" || status == "cancelled" + if out.Finished { + if err := s.db.QueryRow(ctx, `SELECT COALESCE(AVG(bid_amount)::bigint,0) FROM star_gift_auction_acquired WHERE gift_id=$1`, giftID). + Scan(&out.AveragePrice); err != nil { + return domain.StarGiftAuction{}, err + } + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=$1`, giftID). + Scan(&out.ListedCount); err != nil { + return domain.StarGiftAuction{}, err + } + } + rows, err := s.db.Query(ctx, `SELECT amount,bid_date FROM star_gift_auction_bids WHERE gift_id=$1 AND active +ORDER BY amount DESC,bid_date,bidder_user_id LIMIT 20`, giftID) + if err != nil { + return domain.StarGiftAuction{}, err + } + for rows.Next() { + var level domain.StarGiftAuctionBidLevel + level.Pos = len(out.BidLevels) + 1 + if err := rows.Scan(&level.Amount, &level.Date); err != nil { + rows.Close() + return domain.StarGiftAuction{}, err + } + out.BidLevels = append(out.BidLevels, level) + } + rows.Close() + topRows, err := s.db.Query(ctx, `SELECT bidder_user_id FROM star_gift_auction_bids WHERE gift_id=$1 AND active +ORDER BY amount DESC,bid_date,bidder_user_id LIMIT 3`, giftID) + if err != nil { + return domain.StarGiftAuction{}, err + } + for topRows.Next() { + var id int64 + if err := topRows.Scan(&id); err != nil { + topRows.Close() + return domain.StarGiftAuction{}, err + } + out.TopBidders = append(out.TopBidders, id) + } + topRows.Close() + var peerType string + var active bool + err = s.db.QueryRow(ctx, `SELECT returned,active,amount,bid_date,recipient_peer_type,recipient_peer_id,acquired_count +FROM star_gift_auction_bids WHERE gift_id=$1 AND bidder_user_id=$2`, giftID, userID).Scan(&out.UserState.Returned, + &active, &out.UserState.BidAmount, &out.UserState.BidDate, &peerType, &out.UserState.BidPeer.ID, &out.UserState.AcquiredCount) + if err == nil { + if active || out.UserState.Returned { + out.UserState.BidPeer.Type = domain.PeerType(peerType) + out.UserState.MinBidAmount = out.UserState.BidAmount + 1 + } else { + out.UserState.BidAmount, out.UserState.BidDate, out.UserState.BidPeer = 0, 0, domain.Peer{} + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftAuction{}, err + } + return out, nil +} + +func (s *StarGiftLifecycleStore) loadAuctionBidReplay(ctx context.Context, userID, formID, giftID int64) (domain.StarsBalance, bool, error) { + var storedGiftID, balance int64 + err := s.db.QueryRow(ctx, `SELECT gift_id,balance_after FROM star_gift_auction_bid_payments WHERE user_id=$1 AND form_id=$2`, userID, formID). + Scan(&storedGiftID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, false, nil + } + if err != nil { + return domain.StarsBalance{}, false, err + } + if storedGiftID != giftID { + return domain.StarsBalance{}, false, domain.ErrStarGiftAuctionUnavailable + } + return domain.StarsBalance{UserID: userID, Balance: balance}, true, nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/store/postgres/star_gift_craft_projection.go b/internal/store/postgres/star_gift_craft_projection.go new file mode 100644 index 00000000..eed8a216 --- /dev/null +++ b/internal/store/postgres/star_gift_craft_projection.go @@ -0,0 +1,227 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// markCraftInputMessagesTx makes the chat projection part of the same commit +// as the craft outcome. TDesktop derives the Craft entry directly from the +// messageActionStarGiftUnique snapshot, so changing only peer_star_gifts and +// unique_star_gifts would leave an already-burned input actionable. +func (s *StarGiftLifecycleStore) markCraftInputMessagesTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftCraftRequest, + savedIDs []int64, +) ([]domain.EditedMessageForUser, []int32, error) { + edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)*2) + ownerPTS := make([]int32, 0, len(savedIDs)) + for _, savedID := range savedIDs { + saved, found, err := savedStarGiftByID(ctx, tx, savedID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UniqueGiftID <= 0 || saved.UpgradeMsgID <= 0 { + if err != nil { + return nil, nil, err + } + return nil, nil, domain.ErrStarGiftCraftUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil || !found { + if err != nil { + return nil, nil, err + } + return nil, nil, domain.ErrStarGiftCraftUnavailable + } + inputEdits, ownerPT, err := s.markCraftInputMessageTx(ctx, tx, req, saved, unique) + if err != nil { + return nil, nil, err + } + edits = append(edits, inputEdits...) + ownerPTS = append(ownerPTS, int32(ownerPT)) + } + return edits, ownerPTS, nil +} + +func (s *StarGiftLifecycleStore) markCraftInputMessageTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftCraftRequest, + saved domain.SavedStarGift, + unique domain.UniqueStarGift, +) ([]domain.EditedMessageForUser, int, error) { + q := sqlcgen.New(tx) + target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.UserID, + BoxID: int32(saved.UpgradeMsgID), + PeerType: string(domain.PeerTypeUser), + PeerID: saved.FromUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, domain.ErrStarGiftCraftUnavailable + } + return nil, 0, fmt.Errorf("lock craft input message: %w", err) + } + boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID), + MessageSenderID: target.MessageSenderID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return nil, 0, fmt.Errorf("list craft input message boxes: %w", err) + } + if len(boxes) == 0 { + return nil, 0, domain.ErrStarGiftCraftUnavailable + } + + edits := make([]domain.EditedMessageForUser, 0, len(boxes)) + ownerPTS := 0 + var privateMediaJSON []byte + for _, box := range boxes { + media, err := decodeMessageMedia(box.MediaJson) + if err != nil { + return nil, 0, fmt.Errorf("decode craft input message media: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil || + media.ServiceAction.StarGiftUnique.Gift.ID != unique.ID { + return nil, 0, fmt.Errorf("craft input message %d has invalid unique gift projection", box.BoxID) + } + action := media.ServiceAction.StarGiftUnique + action.Gift = unique + action.Saved = saved.LifecycleStatus.Live() && !saved.Unsaved + action.CanExportAt = saved.CanExportAt + action.TransferStars = saved.TransferStars + action.CanTransferAt = saved.CanTransferAt + action.CanResellAt = saved.CanResellAt + action.DropOriginalDetailsStars = saved.DropOriginalDetailsStars + action.CanCraftAt = saved.CanCraftAt + + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return nil, 0, fmt.Errorf("encode craft input message media: %w", err) + } + pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return nil, 0, fmt.Errorf("allocate craft input edit pts: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media=$3,pts=$4 +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts)) + if err != nil { + return nil, 0, fmt.Errorf("update craft input message box: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, 0, fmt.Errorf("update craft input message box lost row") + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return nil, 0, err + } + msg.Media = media + msg.Pts = pts + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return nil, 0, err + } + event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage, + Pts: pts, PtsCount: 1, Date: req.Date, Message: msg} + if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil { + return nil, 0, fmt.Errorf("append craft input edit event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.UserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + ownerPTS = pts + } + if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID, + }); err != nil { + return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err) + } + if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { + privateMediaJSON = mediaJSON + } + edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if ownerPTS <= 0 || len(privateMediaJSON) == 0 { + return nil, 0, fmt.Errorf("craft input message missing owner projection") + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media=$3 +WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil { + return nil, 0, fmt.Errorf("update craft input private message: %w", err) + } + return edits, ownerPTS, nil +} + +func (s *StarGiftLifecycleStore) loadCraftInputMessageReplays( + ctx context.Context, + req domain.StarGiftCraftRequest, + savedIDs []int64, + ptsValues []int32, +) ([]domain.EditedMessageForUser, error) { + if len(savedIDs) != len(ptsValues) { + return nil, domain.ErrStarGiftCraftUnavailable + } + edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)) + for i, savedID := range savedIDs { + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || + saved.UpgradeMsgID <= 0 || ptsValues[i] <= 0 { + if err != nil { + return nil, err + } + return nil, domain.ErrStarGiftCraftUnavailable + } + var privateMessageID, messageSenderID int64 + err = s.db.QueryRow(ctx, ` +SELECT private_message_id,message_sender_id FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`, + req.UserID, saved.UpgradeMsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("load craft input replay message: %w", err) + } + boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("load craft input replay box: %w", err) + } + if len(boxes) != 1 || int(boxes[0].BoxID) != saved.UpgradeMsgID { + return nil, domain.ErrStarGiftCraftUnavailable + } + var eventDate int + err = s.db.QueryRow(ctx, ` +SELECT date FROM user_update_events +WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, + req.UserID, ptsValues[i], saved.UpgradeMsgID).Scan(&eventDate) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCraftUnavailable + } + return nil, fmt.Errorf("load craft input replay event: %w", err) + } + msg, err := messageFromVisibleBoxRow(boxes[0]) + if err != nil { + return nil, err + } + msg.Pts = int(ptsValues[i]) + event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, + Pts: int(ptsValues[i]), PtsCount: 1, Date: eventDate, Message: msg} + edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event}) + } + return edits, nil +} diff --git a/internal/store/postgres/star_gift_entitlements.go b/internal/store/postgres/star_gift_entitlements.go new file mode 100644 index 00000000..b5f71d1d --- /dev/null +++ b/internal/store/postgres/star_gift_entitlements.go @@ -0,0 +1,282 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *StarGiftLifecycleStore) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) { + hash = strings.TrimSpace(hash) + if s == nil || s.db == nil || !validLifecyclePeer(owner) || len(hash) < 32 || len(hash) > 256 { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + row := s.db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3`, + string(owner.Type), owner.ID, hash) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 || saved.PrepaidUpgradeStars != 0 { + if err != nil { + return domain.SavedStarGift{}, 0, err + } + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + revision, err := locklessActiveCollectibleRevision(ctx, s.db, saved.GiftID) + if err != nil || revision.UpgradeStars <= 0 || revision.Issued >= revision.SupplyTotal { + return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable + } + return saved, revision.UpgradeStars, nil +} + +func locklessActiveCollectibleRevision(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, giftID int64) (domain.StarGiftCollectibleRevision, error) { + var revision domain.StarGiftCollectibleRevision + var status string + err := db.QueryRow(ctx, `SELECT r.id,r.gift_id,r.upgrade_stars,r.supply_total,r.issued,r.slug_prefix,r.status +FROM star_gift_catalog c JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id +WHERE c.gift_id=$1`, giftID).Scan(&revision.ID, &revision.GiftID, &revision.UpgradeStars, + &revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status) + if err != nil || status != "published" { + return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable + } + return revision, nil +} + +func (s *StarGiftLifecycleStore) PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) { + req.Hash, req.CommandKey = strings.TrimSpace(req.Hash), strings.TrimSpace(req.CommandKey) + if s == nil || s.messages == nil || req.PayerUserID <= 0 || !validLifecyclePeer(req.Owner) || + len(req.Hash) < 32 || len(req.Hash) > 256 || req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + if replay, found, err := s.loadPrepaidUpgradeReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found { + return replay, err + } + if req.ChargeStars <= 0 { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + target, price, err := s.PrepaidUpgradeTarget(ctx, req.Owner, req.Hash) + if err != nil || price != req.ChargeStars { + return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable + } + fingerprint := sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-prepay:v2:%d:%s:%d:%s:%d:%d", req.PayerUserID, + req.Owner.Type, req.Owner.ID, req.Hash, req.FormID, req.ChargeStars))) + placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true, CanUpgrade: true, UpgradeSeparate: true}}} + messageSenderID, recipientUserID := req.PayerUserID, req.Owner.ID + if req.Owner.Type == domain.PeerTypeChannel { + messageSenderID, recipientUserID = domain.OfficialSystemUserID, req.PayerUserID + } + messageReq := domain.SendPrivateTextRequest{SenderUserID: messageSenderID, RecipientUserID: recipientUserID, + RandomID: lifecycleCommandRandomID("prepay", req.PayerUserID, req.Owner.ID, req.Hash), Media: placeholder, Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.PayerUserID, + IdempotencyFingerprint: fingerprint[:]} + var result domain.StarGiftPrepaidUpgradeResult + hooks := privateSendTxHooks{before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error { + locked, err := lockSavedStarGiftByPrepayHash(ctx, tx, req.Owner, req.Hash) + if err != nil || locked.ID != target.ID || !locked.LifecycleStatus.Live() || locked.UniqueGiftID != 0 || locked.PrepaidUpgradeStars != 0 { + return domain.ErrStarGiftCollectibleUnavailable + } + revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID) + if err != nil || revision.UpgradeStars != req.ChargeStars || revision.Issued >= revision.SupplyTotal { + return domain.ErrStarGiftCollectibleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.PayerUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftPrepaid, req.Owner, req.Date, "Prepaid star gift upgrade") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET prepaid_upgrade_stars=$2,prepaid_upgrade_hash='' WHERE id=$1`, locked.ID, req.ChargeStars); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_prepaid_upgrade_commands(payer_user_id,command_key,saved_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, locked.RevisionID) + if err != nil || !found { + return domain.ErrStarGiftCollectibleUnavailable + } + sticker := gift.Sticker + action := &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: locked.ConvertStars, Title: gift.Title, Sticker: &sticker, + FromUserID: req.PayerUserID, To: req.Owner, SavedID: locked.SavedID, Saved: true, CanUpgrade: true, + PrepaidUpgrade: true, UpgradeSeparate: true, UpgradePriceStars: req.ChargeStars, + UpgradeStars: req.ChargeStars, GiftMsgID: locked.MsgID, + } + if req.Owner.Type == domain.PeerTypeChannel { + action.PeerChannelID = req.Owner.ID + } else { + action.PeerUserID = req.Owner.ID + } + messageReq.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: action.GiftID, Stars: action.Stars, ConvertStars: action.ConvertStars, Title: action.Title, + Sticker: action.Sticker, FromUserID: action.FromUserID, PeerUserID: action.PeerUserID, + PeerChannelID: action.PeerChannelID, To: action.To, SavedID: action.SavedID, Saved: action.Saved, + CanUpgrade: action.CanUpgrade, PrepaidUpgrade: action.PrepaidUpgrade, UpgradeSeparate: action.UpgradeSeparate, + UpgradePriceStars: action.UpgradePriceStars, UpgradeStars: action.UpgradeStars, GiftMsgID: action.GiftMsgID}}} + locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, "" + result.Saved, result.Balance = locked, balance + return nil + }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + if req.Owner.Type != domain.PeerTypeChannel { + return nil + } + action := messageReq.Media.ServiceAction.StarGift + return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID, + result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action}) + }} + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPrepaidUpgradeResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + replay, _, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent) + return replay, replayErr + } + return result, nil +} + +func lockSavedStarGiftByPrepayHash(ctx context.Context, tx pgx.Tx, owner domain.Peer, hash string) (domain.SavedStarGift, error) { + row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3 FOR UPDATE`, + string(owner.Type), owner.ID, hash) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, domain.ErrStarGiftCollectibleUnavailable + } + return saved, err +} + +func (s *StarGiftLifecycleStore) loadPrepaidUpgradeReplay(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPrepaidUpgradeResult, bool, error) { + var savedID, balance int64 + err := s.db.QueryRow(ctx, `SELECT saved_gift_id,balance_after FROM star_gift_prepaid_upgrade_commands WHERE payer_user_id=$1 AND command_key=$2`, + req.PayerUserID, req.CommandKey).Scan(&savedID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftPrepaidUpgradeResult{}, false, nil + } + if err != nil { + return domain.StarGiftPrepaidUpgradeResult{}, false, err + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftPrepaidUpgradeResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + return domain.StarGiftPrepaidUpgradeResult{Saved: saved, Balance: domain.StarsBalance{UserID: req.PayerUserID, Balance: balance}, Send: sent, Duplicate: true}, true, nil +} + +func (s *StarGiftLifecycleStore) DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) { + req.CommandKey = strings.TrimSpace(req.CommandKey) + if s == nil || s.db == nil || req.UserID <= 0 || !req.Ref.Valid() || + (req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || !validLifecyclePeer(req.Ref.Owner) || + req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + if replay, found, err := s.loadDropDetailsReplay(ctx, req); err != nil || found { + return replay, err + } + if req.ChargeStars <= 0 { + return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable + } + var result domain.StarGiftDropOriginalDetailsResult + err := withTx(ctx, s.db, "drop star gift original details", func(tx pgx.Tx) error { + saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, req.UserID, req.Ref) + if err != nil || saved.DropOriginalDetailsStars != req.ChargeStars || !unique.KeepOriginalDetails { + return domain.ErrStarGiftCollectibleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.UserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftDrop, saved.Owner, req.Date, "Drop star gift original details") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET keep_original_details=false,updated_at=now() WHERE id=$1`, unique.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET drop_original_details_stars=0 WHERE id=$1`, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_drop_details_commands(user_id,command_key,saved_gift_id,unique_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, req.UserID, req.CommandKey, saved.ID, unique.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + saved.DropOriginalDetailsStars, unique.KeepOriginalDetails = 0, false + result = domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, Balance: balance} + return nil + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadDropDetailsReplay(ctx, req); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftDropOriginalDetailsResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) loadDropDetailsReplay(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, bool, error) { + var savedID, uniqueID, balance int64 + err := s.db.QueryRow(ctx, `SELECT saved_gift_id,unique_gift_id,balance_after FROM star_gift_drop_details_commands WHERE user_id=$1 AND command_key=$2`, + req.UserID, req.CommandKey).Scan(&savedID, &uniqueID, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftDropOriginalDetailsResult{}, false, nil + } + if err != nil { + return domain.StarGiftDropOriginalDetailsResult{}, false, err + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable + } + return domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, + Balance: domain.StarsBalance{UserID: req.UserID, Balance: balance}, Duplicate: true}, true, nil +} + +func savedStarGiftByID(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, savedID int64) (domain.SavedStarGift, bool, error) { + row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, +p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, +p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, +p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, +COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i +JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) +FROM peer_star_gifts p WHERE p.id=$1`, savedID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} diff --git a/internal/store/postgres/star_gift_integration_test.go b/internal/store/postgres/star_gift_integration_test.go index 778a013b..17ba3ce1 100644 --- a/internal/store/postgres/star_gift_integration_test.go +++ b/internal/store/postgres/star_gift_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "testing" "time" @@ -57,13 +58,16 @@ func TestStarGiftStorePostgres(t *testing.T) { }) // 创建三份礼物(msg_id 递增)。 + savedIDs := make([]int64, 3) for i := 0; i < 3; i++ { - if _, err := st.Create(ctx, domain.SavedStarGift{ + savedID, err := st.Create(ctx, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 50, - }); err != nil { + }) + if err != nil { t.Fatalf("create gift #%d: %v", i, err) } + savedIDs[i] = savedID } // active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。 @@ -113,6 +117,35 @@ func TestStarGiftStorePostgres(t *testing.T) { t.Fatalf("page2 = %d next %q, want 1 + empty (terminal)", len(page2.Gifts), page2.NextOffset) } + // 资料页顺序:完整 pin vector 的顺序必须成为列表前缀;游标即使切在 + // pinned block 内或 pinned/unpinned 边界,也不能重复或漏项。 + if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil { + t.Fatalf("set pinned profile order: %v", err) + } + wantMsgIDs := []int{100, 102, 101} + gotMsgIDs := make([]int, 0, len(wantMsgIDs)) + offset := "" + for pageNumber := 0; ; pageNumber++ { + page, err := st.ListByOwner(ctx, ownerPeer, false, offset, 1) + if err != nil { + t.Fatalf("list pinned page %d: %v", pageNumber, err) + } + if page.Count != 3 || len(page.Gifts) != 1 { + t.Fatalf("pinned page %d = %+v, want count=3 and one gift", pageNumber, page) + } + gotMsgIDs = append(gotMsgIDs, page.Gifts[0].MsgID) + if page.NextOffset == "" { + break + } + offset = page.NextOffset + } + if !slices.Equal(gotMsgIDs, wantMsgIDs) { + t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs) + } + if err := st.SetPinned(ctx, ownerPeer, nil); err != nil { + t.Fatalf("clear pinned profile order: %v", err) + } + // 隐藏 msg_id=101 → excludeUnsaved 列表少一份。 if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 101}, true); err != nil || !ok { t.Fatalf("set unsaved = %v err %v", ok, err) diff --git a/internal/store/postgres/star_gift_lifecycle.go b/internal/store/postgres/star_gift_lifecycle.go new file mode 100644 index 00000000..d54c4c4d --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle.go @@ -0,0 +1,1693 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +type StarGiftLifecycleStore struct { + db sqlcgen.DBTX + messages *MessageStore + tonStartingGrant int64 + market domain.StarGiftMarketPolicy + craftDraw func(int) (int, error) +} + +type StarGiftLifecycleOption func(*StarGiftLifecycleStore) + +func WithStarGiftMarketPolicy(policy domain.StarGiftMarketPolicy) StarGiftLifecycleOption { + return func(s *StarGiftLifecycleStore) { + if policy.Valid() { + s.market = policy + } + } +} + +// WithStarGiftCraftDraw replaces the cryptographically random craft draw. +// It exists so integration tests can cover both terminal outcomes without +// probabilistic retries; production constructors use defaultStarGiftCraftDraw. +func WithStarGiftCraftDraw(draw func(int) (int, error)) StarGiftLifecycleOption { + return func(s *StarGiftLifecycleStore) { + if draw != nil { + s.craftDraw = draw + } + } +} + +func NewStarGiftLifecycleStore(db sqlcgen.DBTX, messages *MessageStore, tonStartingGrant int64, opts ...StarGiftLifecycleOption) *StarGiftLifecycleStore { + if tonStartingGrant < 0 { + tonStartingGrant = 0 + } + s := &StarGiftLifecycleStore{db: db, messages: messages, tonStartingGrant: tonStartingGrant, + market: domain.StarGiftMarketPolicy{StarsProceedsPermille: 1000, TONProceedsPermille: 1000}, + craftDraw: defaultStarGiftCraftDraw} + for _, opt := range opts { + opt(s) + } + return s +} + +// ConvertStarGift owns the complete conversion aggregate: saved-gift terminal +// state, collection membership, owner-scoped Stars balance and transaction log. +// A channel conversion credits the channel ledger, never ActorUserID's personal +// balance. No external payment or blockchain system participates. +func (s *StarGiftLifecycleStore) ConvertStarGift(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) { + if s == nil || s.db == nil || req.ActorUserID <= 0 || !req.Ref.Valid() || req.Date <= 0 { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftNotFound + } + if req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.ActorUserID { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftOwnerInvalid + } + if !validLifecyclePeer(req.Ref.Owner) { + return domain.StarGiftConvertResult{}, domain.ErrStarGiftOwnerInvalid + } + + var result domain.StarGiftConvertResult + err := withTx(ctx, s.db, "convert star gift aggregate", func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, starGiftCollectionLockKey(req.Ref.Owner)); err != nil { + return fmt.Errorf("lock star gift owner collections: %w", err) + } + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if saved.Converted || saved.LifecycleStatus == domain.StarGiftLifecycleConverted { + return domain.ErrStarGiftAlreadyConverted + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 { + return domain.ErrStarGiftAlreadyUpgraded + } + + from := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID} + amount := saved.ConvertStars + var balanceAfter int64 + switch saved.Owner.Type { + case domain.PeerTypeUser: + if amount > 0 { + if err := s.creditLifecycleAmount(ctx, tx, saved.Owner.ID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: amount}, + domain.StarsReasonGift, from, req.Date, "Star gift conversion"); err != nil { + return err + } + } + if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM stars_balances WHERE user_id=$1),0)`, saved.Owner.ID).Scan(&balanceAfter); err != nil { + return err + } + case domain.PeerTypeChannel: + if amount > 0 { + if err := tx.QueryRow(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now() + RETURNING balance`, saved.Owner.ID, amount).Scan(&balanceAfter); err != nil { + return fmt.Errorf("credit channel star gift conversion: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions + (channel_id,actor_user_id,amount,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, saved.Owner.ID, req.ActorUserID, amount, + string(domain.StarsReasonGift), string(from.Type), from.ID, saved.GiftID, req.Date); err != nil { + return fmt.Errorf("record channel star gift conversion: %w", err) + } + } else if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, saved.Owner.ID).Scan(&balanceAfter); err != nil { + return err + } + } + + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts + SET converted=true,lifecycle_status='converted',unsaved=true,pinned_order=0 + WHERE id=$1`, saved.ID); err != nil { + return fmt.Errorf("mark star gift converted: %w", err) + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_conversions + (saved_gift_id,actor_user_id,owner_peer_type,owner_peer_id,amount,balance_after,converted_at) + VALUES($1,$2,$3,$4,$5,$6,$7)`, saved.ID, req.ActorUserID, string(saved.Owner.Type), + saved.Owner.ID, amount, balanceAfter, req.Date); err != nil { + return fmt.Errorf("record star gift conversion command: %w", err) + } + saved.Converted = true + saved.LifecycleStatus = domain.StarGiftLifecycleConverted + saved.Unsaved = true + saved.PinnedOrder = 0 + saved.CollectionIDs = nil + result = domain.StarGiftConvertResult{Saved: saved, OwnerBalance: balanceAfter} + return nil + }) + if err != nil { + return domain.StarGiftConvertResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) ListResaleStarGifts(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) { + if s == nil || s.db == nil || filter.GiftID <= 0 || filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit || + filter.SortByPrice && filter.SortByNum || len(filter.Offset) > domain.MaxStarGiftsOffsetBytes { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + conditions := []string{"u.gift_id=$1", "NOT u.burned", "u.owner_address=''"} + args := []any{filter.GiftID} + nextArg := func(value any) string { + args = append(args, value) + return "$" + strconv.Itoa(len(args)) + } + if filter.StarsOnly { + conditions = append(conditions, "l.currency='XTR'") + } + if filter.ForCraft { + conditions = append(conditions, `u.craft_chance_permille>0 AND EXISTS ( +SELECT 1 FROM star_gift_collectible_models model +WHERE model.collectible_revision_id=u.collectible_revision_id AND model.crafted)`) + } + if len(filter.ModelIDs) > 0 { + conditions = append(conditions, "u.model_attribute_id IN (SELECT id FROM star_gift_collectible_models WHERE document_id=ANY("+nextArg(filter.ModelIDs)+"::bigint[]))") + } + if len(filter.PatternIDs) > 0 { + conditions = append(conditions, "u.pattern_attribute_id IN (SELECT id FROM star_gift_collectible_patterns WHERE document_id=ANY("+nextArg(filter.PatternIDs)+"::bigint[]))") + } + if len(filter.BackdropIDs) > 0 { + conditions = append(conditions, "u.backdrop_attribute_id IN (SELECT id FROM star_gift_collectible_backdrops WHERE backdrop_id::bigint=ANY("+nextArg(filter.BackdropIDs)+"::bigint[]))") + } + order := "l.updated_at DESC, u.id DESC" + if filter.SortByPrice { + order = "l.amount, u.id" + } else if filter.SortByNum { + order = "u.num, u.id" + } + if filter.Offset != "" { + parts := strings.Split(filter.Offset, ":") + if len(parts) != 3 { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + value, valueErr := strconv.ParseInt(parts[1], 10, 64) + id, idErr := strconv.ParseInt(parts[2], 10, 64) + if valueErr != nil || idErr != nil || value < 0 || id <= 0 { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + switch { + case filter.SortByPrice && parts[0] == "p": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(l.amount,u.id)>("+p1+","+p2+")") + case filter.SortByNum && parts[0] == "n": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(u.num,u.id)>("+p1+","+p2+")") + case !filter.SortByPrice && !filter.SortByNum && parts[0] == "d": + p1, p2 := nextArg(value), nextArg(id) + conditions = append(conditions, "(l.updated_at,u.id)<("+p1+","+p2+")") + default: + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + } + where := strings.Join(conditions, " AND ") + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE `+where, args...).Scan(&total); err != nil { + return domain.StarGiftResalePage{}, fmt.Errorf("count resale star gifts: %w", err) + } + limitArg := nextArg(filter.Limit + 1) + rows, err := s.db.Query(ctx, `SELECT u.id,l.amount,l.updated_at,u.num +FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id +WHERE `+where+` ORDER BY `+order+` LIMIT `+limitArg, args...) + if err != nil { + return domain.StarGiftResalePage{}, fmt.Errorf("list resale star gifts: %w", err) + } + defer rows.Close() + type listedID struct { + id, amount int64 + updated, num int + } + listed := make([]listedID, 0, filter.Limit+1) + ids := make([]int64, 0, filter.Limit+1) + for rows.Next() { + var item listedID + if err := rows.Scan(&item.id, &item.amount, &item.updated, &item.num); err != nil { + return domain.StarGiftResalePage{}, err + } + listed = append(listed, item) + ids = append(ids, item.id) + } + if err := rows.Err(); err != nil { + return domain.StarGiftResalePage{}, err + } + hasMore := len(listed) > filter.Limit + if hasMore { + listed, ids = listed[:filter.Limit], ids[:filter.Limit] + } + uniqueByID, err := NewStarGiftStore(s.db).UniqueByIDs(ctx, ids) + if err != nil { + return domain.StarGiftResalePage{}, err + } + page := domain.StarGiftResalePage{Count: total, Gifts: make([]domain.UniqueStarGift, 0, len(ids))} + for _, item := range listed { + gift, ok := uniqueByID[item.id] + if !ok { + return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable + } + page.Gifts = append(page.Gifts, gift) + } + if hasMore && len(listed) > 0 { + last := listed[len(listed)-1] + switch { + case filter.SortByPrice: + page.NextOffset = fmt.Sprintf("p:%d:%d", last.amount, last.id) + case filter.SortByNum: + page.NextOffset = fmt.Sprintf("n:%d:%d", last.num, last.id) + default: + page.NextOffset = fmt.Sprintf("d:%d:%d", last.updated, last.id) + } + } + return page, nil +} + +func (s *StarGiftLifecycleStore) UniqueStarGiftValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) { + var out domain.StarGiftValueInfo + var configuredCurrency string + var configuredValue int64 + err := s.db.QueryRow(ctx, ` +SELECT sg.gift_date, cr.stars, u.value_currency, u.value_amount, u.last_sale_date, + COALESCE(CASE WHEN u.last_sale_currency='XTR' THEN u.last_sale_amount END,0), + COALESCE((SELECT MIN(l.amount) FROM star_gift_listings l JOIN unique_star_gifts lu ON lu.id=l.unique_gift_id WHERE lu.gift_id=u.gift_id AND l.currency='XTR'),0), + COALESCE((SELECT AVG(sa.amount)::bigint FROM star_gift_sales sa JOIN unique_star_gifts su ON su.id=sa.unique_gift_id WHERE su.gift_id=u.gift_id AND sa.currency='XTR'),0), + (SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts lu ON lu.id=l.unique_gift_id WHERE lu.gift_id=u.gift_id) +FROM unique_star_gifts u +JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id +JOIN star_gift_catalog_revisions cr ON cr.id=sg.catalog_revision_id +WHERE u.id=$1`, uniqueGiftID).Scan(&out.InitialSaleDate, &out.InitialSaleStars, &configuredCurrency, + &configuredValue, &out.LastSaleDate, &out.LastSalePrice, &out.FloorPrice, &out.AveragePrice, &out.ListedCount) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftValueInfo{}, domain.ErrStarGiftNotFound + } + if err != nil { + return domain.StarGiftValueInfo{}, fmt.Errorf("star gift value info: %w", err) + } + // The self-hosted ledger has no FX oracle. One Star-cent is the explicit local + // valuation unit unless an operator/provider has stored a real fiat estimate. + out.Currency = "USD" + out.InitialSalePrice = out.InitialSaleStars + if configuredCurrency != "" && configuredValue > 0 { + out.Currency, out.Value = configuredCurrency, configuredValue + } else if out.LastSalePrice > 0 { + out.Value = out.LastSalePrice + } else if out.FloorPrice > 0 { + out.Value = out.FloorPrice + } else { + out.Value = out.InitialSalePrice + } + out.ValueIsAverage = out.AveragePrice > 0 && out.LastSalePrice == 0 + return out, nil +} + +func (s *StarGiftLifecycleStore) SetStarGiftListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) { + if req.ActorUserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || req.Amount != nil && !req.Amount.Valid() { + return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable + } + var uniqueID int64 + err := withTx(ctx, s.db, "set star gift listing", func(tx pgx.Tx) error { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanResellAt > req.Date || saved.Owner != req.Ref.Owner { + return domain.ErrStarGiftResaleUnavailable + } + if saved.Owner.Type == domain.PeerTypeUser && saved.Owner.ID != req.ActorUserID { + return domain.ErrStarGiftOwnerInvalid + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil { + return err + } + if !found || unique.Burned || unique.Owner != saved.Owner || unique.OwnerAddress != "" { + return domain.ErrStarGiftResaleUnavailable + } + uniqueID = unique.ID + if req.Amount == nil { + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, unique.ID); err != nil { + return err + } + } else { + if unique.ResaleTonOnly && req.Amount.Currency != domain.StarGiftCurrencyTON { + return domain.ErrStarGiftResaleUnavailable + } + var minimum int64 + if req.Amount.Currency == domain.StarGiftCurrencyStars { + if err := tx.QueryRow(ctx, `SELECT resell_min_stars FROM star_gift_catalog WHERE gift_id=$1`, unique.GiftID).Scan(&minimum); err != nil { + return err + } + if req.Amount.Amount < minimum { + return domain.ErrStarGiftResaleUnavailable + } + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_listings(unique_gift_id,seller_peer_type,seller_peer_id,currency,amount,listed_at,updated_at) +VALUES($1,$2,$3,$4,$5,$6,$6) +ON CONFLICT(unique_gift_id) DO UPDATE SET currency=EXCLUDED.currency,amount=EXCLUDED.amount,updated_at=EXCLUDED.updated_at,version=star_gift_listings.version+1`, + unique.ID, string(saved.Owner.Type), saved.Owner.ID, string(req.Amount.Currency), req.Amount.Amount, req.Date) + if err != nil { + return err + } + } + return updateStarGiftResaleProjection(ctx, tx, unique.GiftID) + }) + if err != nil { + return domain.UniqueStarGift{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil { + return domain.UniqueStarGift{}, err + } + if !found { + return domain.UniqueStarGift{}, domain.ErrStarGiftNotFound + } + return unique, nil +} + +func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.messages == nil || req.ActorUserID <= 0 || !req.Ref.Valid() || !validLifecyclePeer(req.To) || + req.To == req.Ref.Owner || req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + if req.To.Type != domain.PeerTypeUser { + return s.transferStarGiftWithoutPrivateMessage(ctx, req) + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: req.ActorUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("gift-transfer", req.ActorUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.ActorUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{Transferred: true, Saved: true}, + }}, + } + var result domain.StarGiftTransferResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) + if err != nil { + return err + } + if saved.TransferStars != req.ChargeStars { + return domain.ErrStarGiftTransferUnavailable + } + if err := ensureNoStarGiftMarketConflict(ctx, tx, unique.ID); err != nil { + return err + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.ActorUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftTransfer, req.To, req.Date, "Star gift transfer") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,updated_at=now() WHERE id=$1`, + unique.ID, string(req.To.Type), req.To.ID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + unique.Owner = req.To + saved.Owner = req.To + result.Saved, result.Unique, result.Balance = saved, unique, balance + send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(unique, req.ActorUserID, req.To, saved) + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID := sent.RecipientMessage.ID + if req.ActorUserID == req.To.ID { + msgID = sent.SenderMessage.ID + } + if msgID <= 0 { + return domain.ErrStarGiftTransferUnavailable + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3, + msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0, + can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID, + string(req.Ref.Owner.Type), req.Ref.Owner.ID, string(req.To.Type), req.To.ID, req.ChargeStars, result.Balance.Balance, req.Date); err != nil { + return err + } + result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date + result.Saved.FromUserID = req.ActorUserID + return nil + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftTransferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadTransferReplay(ctx, req, sent) + } + return result, nil +} + +func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) { + if s == nil || s.messages == nil || req.BuyerUserID <= 0 || strings.TrimSpace(req.Slug) == "" || + !validLifecyclePeer(req.To) || !req.Amount.Valid() || req.FormID == 0 || + strings.TrimSpace(req.CommandKey) == "" || req.Date <= 0 { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + unique, found, err := NewStarGiftStore(s.db).UniqueBySlug(ctx, req.Slug) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + seller := unique.Owner + var replayUniqueID, replayFromID, replayToID, replayAmount int64 + var replayFromType, replayToType, replayCurrency string + replayErr := s.db.QueryRow(ctx, `SELECT t.unique_gift_id,t.from_peer_type,t.from_peer_id,t.to_peer_type,t.to_peer_id, + s.currency,s.amount FROM star_gift_transfer_commands t + JOIN star_gift_sales s ON s.command_key=t.command_key AND s.unique_gift_id=t.unique_gift_id + WHERE t.actor_user_id=$1 AND t.command_key=$2`, req.BuyerUserID, strings.TrimSpace(req.CommandKey)).Scan( + &replayUniqueID, &replayFromType, &replayFromID, &replayToType, &replayToID, &replayCurrency, &replayAmount) + if replayErr == nil { + if replayUniqueID != unique.ID || replayToType != string(req.To.Type) || replayToID != req.To.ID || + replayCurrency != string(req.Amount.Currency) || replayAmount != req.Amount.Amount { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + seller = domain.Peer{Type: domain.PeerType(replayFromType), ID: replayFromID} + } else if !errors.Is(replayErr, pgx.ErrNoRows) { + return domain.StarGiftTransferResult{}, replayErr + } else if !validLifecyclePeer(unique.Owner) || unique.Owner == req.To { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable + } + messageSenderID := domain.OfficialSystemUserID + if seller.Type == domain.PeerTypeUser { + messageSenderID = seller.ID + } + messageRecipientID := req.BuyerUserID + if req.To.Type == domain.PeerTypeUser { + messageRecipientID = req.To.ID + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: messageSenderID, RecipientUserID: messageRecipientID, + RandomID: lifecycleCommandRandomID("gift-resale", req.BuyerUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{Transferred: true, Saved: true}, + }}, + } + var result domain.StarGiftTransferResult + var commissionAmount int64 + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + var listingCurrency, sellerType string + var listingAmount, sellerID, uniqueID int64 + if err := tx.QueryRow(ctx, `SELECT l.currency,l.amount,l.seller_peer_type,l.seller_peer_id,u.id + FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id + WHERE lower(u.slug)=lower($1) FOR UPDATE OF l,u`, strings.TrimSpace(req.Slug)).Scan( + &listingCurrency, &listingAmount, &sellerType, &sellerID, &uniqueID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftResaleUnavailable + } + return err + } + if sellerType != string(seller.Type) || sellerID != seller.ID || + listingCurrency != string(req.Amount.Currency) || listingAmount != req.Amount.Amount { + return domain.ErrStarGiftResaleUnavailable + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, uniqueID) + if err != nil || !found || !saved.LifecycleStatus.Live() || saved.Owner != seller { + return domain.ErrStarGiftResaleUnavailable + } + gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) + if err != nil || !found || gift.Burned || gift.Owner != saved.Owner { + return domain.ErrStarGiftResaleUnavailable + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, req.Amount, domain.StarsReasonGiftResale, + saved.Owner, req.Date, "Collectible gift purchase") + if err != nil { + return err + } + if _, commission, err := s.creditPeerLifecycleAmount(ctx, tx, seller, req.BuyerUserID, req.Amount, + domain.StarsReasonGiftResale, domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, + gift.ID, req.Date, "Collectible gift sale"); err != nil { + return err + } else { + commissionAmount = commission + } + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, req.Date, "listing purchased"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, uniqueID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=$2,owner_peer_id=$3, + last_sale_date=$4,last_sale_currency=$5,last_sale_amount=$6,updated_at=now() WHERE id=$1`, + uniqueID, string(req.To.Type), req.To.ID, req.Date, listingCurrency, listingAmount); err != nil { + return err + } + gift.Owner = req.To + gift.ResellAmount = nil + gift.LastSaleDate = req.Date + gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount} + saved.Owner = req.To + if req.To.Type == domain.PeerTypeChannel { + saved.MsgID, saved.SavedID = 0, saved.ID + } + result.Saved, result.Unique, result.Balance = saved, gift, balance + send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved) + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID, savedID := sent.RecipientMessage.ID, int64(0) + if req.To.Type == domain.PeerTypeChannel { + msgID, savedID = 0, result.Saved.ID + } + if req.To.Type == domain.PeerTypeUser && msgID <= 0 { + return domain.ErrStarGiftResaleUnavailable + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,from_user_id=$4, + msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 + WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, + buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID, + string(req.To.Type), req.To.ID, string(req.Amount.Currency), req.Amount.Amount, commissionAmount, + req.Date, strings.TrimSpace(req.CommandKey)); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,0,$8,$9)`, req.BuyerUserID, strings.TrimSpace(req.CommandKey), + result.Unique.ID, string(seller.Type), seller.ID, string(req.To.Type), req.To.ID, result.Balance.Balance, req.Date); err != nil { + return err + } + if req.To.Type == domain.PeerTypeChannel { + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGiftUnique, + StarGiftUnique: transferUniqueAction(result.Unique, messageSenderID, req.To, result.Saved)} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, + result.Saved.ID, req.Date, action); err != nil { + return err + } + } + result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date + result.Saved.FromUserID = messageSenderID + return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftTransferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadTransferReplay(ctx, domain.StarGiftTransferRequest{ActorUserID: req.BuyerUserID, CommandKey: req.CommandKey}, sent) + } + return result, nil +} + +func lockSavedStarGiftByUniqueID(ctx context.Context, tx pgx.Tx, uniqueID int64) (domain.SavedStarGift, bool, error) { + row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, + p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, + p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, + p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, + COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i + JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) + FROM peer_star_gifts p WHERE p.unique_gift_id=$1 FOR UPDATE`, uniqueID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} + +func (s *StarGiftLifecycleStore) refundPendingStarGiftOffers(ctx context.Context, tx pgx.Tx, uniqueID int64, date int, reason string) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,currency,amount,owner_peer_type,owner_peer_id + FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending' FOR UPDATE`, uniqueID) + if err != nil { + return err + } + type pending struct { + id, buyer, amount, ownerID int64 + currency, ownerType string + } + items := make([]pending, 0) + for rows.Next() { + var item pending + if err := rows.Scan(&item.id, &item.buyer, &item.currency, &item.amount, &item.ownerType, &item.ownerID); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + rows.Close() + for _, item := range items { + if err := s.creditLifecycleAmount(ctx, tx, item.buyer, domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerType(item.ownerType), ID: item.ownerID}, date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='cancelled',resolved_at=$2 WHERE id=$1`, item.id, date); err != nil { + return err + } + } + _ = reason + return nil +} + +func (s *StarGiftLifecycleStore) SendStarGiftOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.messages == nil || req.BuyerUserID <= 0 || req.Owner.Type != domain.PeerTypeUser || + req.Owner.ID <= 0 || req.Owner.ID == req.BuyerUserID || strings.TrimSpace(req.Slug) == "" || + !req.Price.Valid() || !validStarGiftOfferDuration(req.Duration) || req.RandomID == 0 || req.Date <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + if err := s.expireStarGiftOffers(ctx, req.Date); err != nil { + return domain.StarGiftOfferResult{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueBySlug(ctx, req.Slug) + if err != nil || !found || unique.Owner != req.Owner || unique.Burned || unique.OwnerAddress != "" || unique.OfferMinStars <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.Owner.ID, RandomID: req.RandomID, Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftOffer, StarGiftOffer: &domain.MessageStarGiftOfferAction{ + Gift: unique, Price: req.Price, ExpiresAt: req.Date + req.Duration, + }, + }}, + } + var result domain.StarGiftOfferResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + // Serialize a new pending offer with Craft/transfer/export, all of + // which close market claims before changing the gift lifecycle. + if _, err := tx.Exec(ctx, `SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, unique.ID); err != nil { + return err + } + gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, unique.ID) + if err != nil || !found || gift.Owner != req.Owner || gift.Burned || gift.OwnerAddress != "" || gift.OfferMinStars <= 0 { + return domain.ErrStarGiftOfferInvalid + } + if req.Price.Currency == domain.StarGiftCurrencyStars && gift.OfferMinStars > 0 && req.Price.Amount < int64(gift.OfferMinStars) { + return domain.ErrStarGiftOfferInvalid + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, req.Price, domain.StarsReasonGiftOffer, + req.Owner, req.Date, "Collectible gift offer") + if err != nil { + return err + } + var offerID int64 + if err := tx.QueryRow(ctx, `INSERT INTO star_gift_offers(buyer_user_id,owner_peer_type,owner_peer_id, + unique_gift_id,currency,amount,random_id,created_at,expires_at,balance_after) + VALUES($1,'user',$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, req.BuyerUserID, req.Owner.ID, + gift.ID, string(req.Price.Currency), req.Price.Amount, req.RandomID, req.Date, req.Date+req.Duration, balance.Balance).Scan(&offerID); err != nil { + return err + } + result.Offer = domain.StarGiftOffer{ID: offerID, BuyerUserID: req.BuyerUserID, Owner: req.Owner, + UniqueGiftID: gift.ID, Price: req.Price, RandomID: req.RandomID, Status: "pending", + CreatedAt: req.Date, ExpiresAt: req.Date + req.Duration, Gift: gift} + result.Balance = balance + send.Media.ServiceAction.StarGiftOffer.Gift = gift + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + ownerMsgID := sent.RecipientMessage.ID + buyerMsgID := sent.SenderMessage.ID + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET offer_msg_id=$2,buyer_msg_id=$3 WHERE id=$1`, result.Offer.ID, ownerMsgID, buyerMsgID); err != nil { + return err + } + result.Offer.OfferMsgID, result.Offer.BuyerMsgID = ownerMsgID, buyerMsgID + return nil + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + return s.loadOfferByBuyerRandom(ctx, req.BuyerUserID, req.RandomID, sent) + } + return result, nil +} + +func validStarGiftOfferDuration(duration int) bool { + switch duration { + case 120, 21600, 43200, 86400, 129600, 172800, 259200: + return true + default: + return false + } +} + +func (s *StarGiftLifecycleStore) loadOfferByBuyerRandom(ctx context.Context, buyerUserID, randomID int64, sent domain.SendPrivateTextResult) (domain.StarGiftOfferResult, error) { + offer, err := scanStarGiftOffer(s.db.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE buyer_user_id=$1 AND random_id=$2`, buyerUserID, randomID)) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, offer.UniqueGiftID) + if err != nil || !found { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + offer.Gift = gift + var balance int64 + if offer.Price.Currency == domain.StarGiftCurrencyTON { + _ = s.db.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1`, buyerUserID).Scan(&balance) + } else { + _ = s.db.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, buyerUserID).Scan(&balance) + } + return domain.StarGiftOfferResult{Offer: offer, Balance: domain.StarsBalance{UserID: buyerUserID, Balance: balance}, Send: sent, Duplicate: true}, nil +} + +func scanStarGiftOffer(row rowScanner) (domain.StarGiftOffer, error) { + var offer domain.StarGiftOffer + var ownerType, currency string + if err := row.Scan(&offer.ID, &offer.BuyerUserID, &ownerType, &offer.Owner.ID, &offer.UniqueGiftID, + ¤cy, &offer.Price.Amount, &offer.RandomID, &offer.OfferMsgID, &offer.BuyerMsgID, + &offer.Status, &offer.CreatedAt, &offer.ExpiresAt, &offer.ResolvedAt, new(int64)); err != nil { + return domain.StarGiftOffer{}, err + } + offer.Owner.Type = domain.PeerType(ownerType) + offer.Price.Currency = domain.StarGiftCurrency(currency) + return offer, nil +} + +func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) { + if s == nil || s.messages == nil || req.OwnerUserID <= 0 || req.OfferMsgID <= 0 || req.Date <= 0 { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + if err := s.expireStarGiftOffers(ctx, req.Date); err != nil { + return domain.StarGiftOfferResult{}, err + } + offer, err := scanStarGiftOffer(s.db.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE owner_peer_type='user' AND owner_peer_id=$1 AND offer_msg_id=$2`, req.OwnerUserID, req.OfferMsgID)) + if err != nil || offer.Status != "pending" || offer.ExpiresAt <= req.Date { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferExpired + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, offer.UniqueGiftID) + if err != nil || !found { + return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid + } + offer.Gift = gift + actionKind := domain.MessageServiceActionStarGiftUnique + action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID}, + Transferred: true, FromOffer: true, Saved: true, + }} + if req.Decline { + action = &domain.MessageServiceAction{Kind: domain.MessageServiceActionStarGiftOfferDeclined, + StarGiftOfferDeclined: &domain.MessageStarGiftOfferDeclinedAction{Gift: gift, Price: offer.Price}} + } + messageReq := domain.SendPrivateTextRequest{SenderUserID: req.OwnerUserID, RecipientUserID: offer.BuyerUserID, + RandomID: lifecycleCommandRandomID("resolve-offer", offer.ID, req.Decline), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.OwnerUserID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} + var result domain.StarGiftOfferResult + var commissionAmount int64 + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, + currency,amount,random_id,offer_msg_id,buyer_msg_id,status,created_at,expires_at,resolved_at,balance_after + FROM star_gift_offers WHERE id=$1 FOR UPDATE`, offer.ID)) + if err != nil || locked.Status != "pending" || locked.ExpiresAt <= req.Date { + return domain.ErrStarGiftOfferExpired + } + locked.Gift = gift + if req.Decline { + if err := s.creditLifecycleAmount(ctx, tx, locked.BuyerUserID, locked.Price, domain.StarsReasonGiftOffer, + locked.Owner, req.Date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='declined',resolved_at=$2,resolution_notified=true WHERE id=$1`, locked.ID, req.Date); err != nil { + return err + } + locked.Status, locked.ResolvedAt = "declined", req.Date + result.Offer = locked + return nil + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, locked.UniqueGiftID) + if err != nil || !found || saved.Owner != locked.Owner || !saved.LifecycleStatus.Live() { + return domain.ErrStarGiftOfferInvalid + } + current, found, err := NewStarGiftStore(tx).UniqueByID(ctx, locked.UniqueGiftID) + if err != nil || !found || current.Owner != locked.Owner || current.Burned || current.OwnerAddress != "" { + return domain.ErrStarGiftOfferInvalid + } + if _, commission, err := s.creditPeerLifecycleAmount(ctx, tx, locked.Owner, req.OwnerUserID, locked.Price, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}, + current.ID, req.Date, "Accepted gift offer"); err != nil { + return err + } else { + commissionAmount = commission + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, current.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='user',owner_peer_id=$2, + last_sale_date=$3,last_sale_currency=$4,last_sale_amount=$5,updated_at=now() WHERE id=$1`, current.ID, + locked.BuyerUserID, req.Date, string(locked.Price.Currency), locked.Price.Amount); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='accepted',resolved_at=$2,resolution_notified=true WHERE id=$1`, locked.ID, req.Date); err != nil { + return err + } + if err := s.refundPendingStarGiftOffersExcept(ctx, tx, current.ID, locked.ID, req.Date); err != nil { + return err + } + current.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID} + current.ResellAmount = nil + current.LastSaleDate = req.Date + current.LastSaleAmount = &locked.Price + locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current + result.Offer = locked + result.Unique = current + result.Saved = saved + send.Media.ServiceAction.StarGiftUnique.Gift = current + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + if req.Decline { + return nil + } + msgID := sent.RecipientMessage.ID + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3, + msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 + WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil { + return err + } + result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID} + result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, + buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key) + VALUES($1,'user',$2,'user',$3,$4,$5,$6,$7,$8)`, result.Offer.UniqueGiftID, req.OwnerUserID, + result.Offer.BuyerUserID, string(result.Offer.Price.Currency), result.Offer.Price.Amount, commissionAmount, + req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil { + return err + } + return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + return domain.StarGiftOfferResult{}, err + } + if sent.Duplicate { + reloaded, loadErr := s.loadOfferByBuyerRandom(ctx, offer.BuyerUserID, offer.RandomID, sent) + if loadErr != nil { + return domain.StarGiftOfferResult{}, loadErr + } + reloaded.Duplicate = true + return reloaded, nil + } + result.Send = sent + return result, nil +} + +func (s *StarGiftLifecycleStore) expireStarGiftOffers(ctx context.Context, now int) error { + if now <= 0 || s.messages == nil { + return nil + } + if _, err := s.expireStarGiftOffersBatch(ctx, now, 100); err != nil { + return err + } + _, err := s.dispatchStarGiftOfferResolutions(ctx, 100) + return err +} + +func (s *StarGiftLifecycleStore) expireStarGiftOffersBatch(ctx context.Context, now, limit int) (int, error) { + if now <= 0 || limit <= 0 { + return 0, nil + } + processed := 0 + err := withTx(ctx, s.db, "expire star gift offers", func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,currency,amount +FROM star_gift_offers WHERE status='pending' AND expires_at<=$1 ORDER BY expires_at,id LIMIT $2 FOR UPDATE SKIP LOCKED`, now, limit) + if err != nil { + return err + } + type expired struct { + id, buyer, ownerID, uniqueID, amount int64 + ownerType, currency string + } + items := make([]expired, 0) + for rows.Next() { + var item expired + if err := rows.Scan(&item.id, &item.buyer, &item.ownerType, &item.ownerID, &item.uniqueID, &item.currency, &item.amount); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, item := range items { + owner := domain.Peer{Type: domain.PeerType(item.ownerType), ID: item.ownerID} + if err := s.creditLifecycleAmount(ctx, tx, item.buyer, + domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, + domain.StarsReasonGiftOffer, owner, now, "Expired gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='expired',resolved_at=$2 WHERE id=$1`, item.id, now); err != nil { + return err + } + } + processed = len(items) + return nil + }) + return processed, err +} + +func (s *StarGiftLifecycleStore) dispatchStarGiftOfferResolutions(ctx context.Context, limit int) (int, error) { + if limit <= 0 || s.messages == nil { + return 0, nil + } + rows, err := s.db.Query(ctx, `SELECT id,buyer_user_id,owner_peer_id,unique_gift_id,currency,amount,resolved_at,status +FROM star_gift_offers WHERE status IN ('expired','cancelled') AND NOT resolution_notified ORDER BY id LIMIT $1`, limit) + if err != nil { + return 0, err + } + type notice struct { + id, buyer, owner, uniqueID, amount int64 + currency, status string + date int + } + items := make([]notice, 0) + for rows.Next() { + var item notice + if err := rows.Scan(&item.id, &item.buyer, &item.owner, &item.uniqueID, &item.currency, &item.amount, &item.date, &item.status); err != nil { + rows.Close() + return 0, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + rows.Close() + for _, item := range items { + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, item.uniqueID) + if err != nil || !found { + return 0, domain.ErrStarGiftOfferInvalid + } + _, err = s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: item.owner, RecipientUserID: item.buyer, + RandomID: lifecycleCommandRandomID("resolve-offer-outbox", item.id, item.status), Date: item.date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftOfferDeclined, StarGiftOfferDeclined: &domain.MessageStarGiftOfferDeclinedAction{ + Gift: gift, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrency(item.currency), Amount: item.amount}, Expired: item.status == "expired"}}}}) + if err != nil { + return 0, err + } + if _, err := s.db.Exec(ctx, `UPDATE star_gift_offers SET resolution_notified=true WHERE id=$1 AND status=$2`, item.id, item.status); err != nil { + return 0, err + } + } + return len(items), nil +} + +func (s *StarGiftLifecycleStore) refundPendingStarGiftOffersExcept(ctx context.Context, tx pgx.Tx, uniqueID, exceptID int64, date int) error { + rows, err := tx.Query(ctx, `SELECT id,buyer_user_id,currency,amount,owner_peer_type,owner_peer_id + FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending' AND id<>$2 FOR UPDATE`, uniqueID, exceptID) + if err != nil { + return err + } + type item struct { + id, buyer, amount, ownerID int64 + currency, ownerType string + } + items := make([]item, 0) + for rows.Next() { + var v item + if err := rows.Scan(&v.id, &v.buyer, &v.currency, &v.amount, &v.ownerType, &v.ownerID); err != nil { + rows.Close() + return err + } + items = append(items, v) + } + rows.Close() + for _, v := range items { + if err := s.creditLifecycleAmount(ctx, tx, v.buyer, domain.StarGiftAmount{Currency: domain.StarGiftCurrency(v.currency), Amount: v.amount}, + domain.StarsReasonGiftOffer, domain.Peer{Type: domain.PeerType(v.ownerType), ID: v.ownerID}, date, "Gift offer refund"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_offers SET status='cancelled',resolved_at=$2 WHERE id=$1`, v.id, date); err != nil { + return err + } + } + return nil +} + +func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) { + var result domain.StarGiftTransferResult + err := withTx(ctx, s.db, "transfer star gift to channel", func(tx pgx.Tx) error { + saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) + if err != nil { + return err + } + if saved.TransferStars != req.ChargeStars { + return domain.ErrStarGiftTransferUnavailable + } + if err := ensureNoStarGiftMarketConflict(ctx, tx, unique.ID); err != nil { + return err + } + balance, err := s.debitLifecycleAmount(ctx, tx, req.ActorUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars}, + domain.StarsReasonGiftTransfer, req.To, req.Date, "Star gift transfer") + if err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,from_user_id=$3, + msg_id=0,saved_id=id,upgrade_msg_id=0,gift_date=$4,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0 WHERE id=$1`, + saved.ID, req.To.ID, req.ActorUserID, req.Date); err != nil { + return err + } + unique.Owner = req.To + saved.Owner, saved.MsgID, saved.SavedID, saved.UpgradeMsgID, saved.Date = req.To, 0, saved.ID, 0, req.Date + saved.FromUserID = req.ActorUserID + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGiftUnique, + StarGiftUnique: transferUniqueAction(unique, req.ActorUserID, req.To, saved)} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.ActorUserID, saved.ID, req.Date, action); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, + from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), unique.ID, + string(req.Ref.Owner.Type), req.Ref.Owner.ID, string(req.To.Type), req.To.ID, req.ChargeStars, balance.Balance, req.Date); err != nil { + return err + } + result.Saved, result.Unique, result.Balance = saved, unique, balance + return nil + }) + return result, err +} + +func lockTransferableStarGift(ctx context.Context, tx pgx.Tx, actorUserID int64, ref domain.SavedStarGiftRef, now int) (domain.SavedStarGift, domain.UniqueStarGift, error) { + saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, actorUserID, ref) + if err != nil || saved.CanTransferAt > now { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + return saved, unique, nil +} + +// lockOwnedUniqueStarGift locks the live ownership aggregate without applying a +// transfer cooldown. Independent capabilities such as dropping original details +// must not be accidentally blocked by can_transfer_at. +func lockOwnedUniqueStarGift(ctx context.Context, tx pgx.Tx, actorUserID int64, ref domain.SavedStarGiftRef) (domain.SavedStarGift, domain.UniqueStarGift, error) { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, ref) + if err != nil { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, err + } + if !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.Owner != ref.Owner || + saved.Owner.Type == domain.PeerTypeUser && saved.Owner.ID != actorUserID { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) + if err != nil { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, err + } + if !found || unique.Burned || unique.OwnerAddress != "" || unique.Owner != saved.Owner { + return domain.SavedStarGift{}, domain.UniqueStarGift{}, domain.ErrStarGiftTransferUnavailable + } + return saved, unique, nil +} + +func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int64) error { + var listing, offers bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM star_gift_listings WHERE unique_gift_id=$1), + EXISTS(SELECT 1 FROM star_gift_offers WHERE unique_gift_id=$1 AND status='pending')`, uniqueID).Scan(&listing, &offers); err != nil { + return err + } + if listing || offers { + return domain.ErrStarGiftTransferUnavailable + } + return nil +} + +func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction { + return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to, + SavedID: saved.SavedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt, + TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt} +} + +func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount, + reason domain.StarsTransactionReason, peer domain.Peer, date int, title string) (domain.StarsBalance, error) { + if amount.Amount == 0 { + var balance domain.StarsBalance + balance.UserID = userID + err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1`, userID).Scan(&balance.Balance, &balance.Granted) + if errors.Is(err, pgx.ErrNoRows) { + return balance, nil + } + return balance, err + } + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := s.ensureTonGrantTx(ctx, tx, userID, date); err != nil { + return domain.StarsBalance{}, err + } + var balance int64 + if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() + WHERE user_id=$1 AND balance_nanoton>=$2 RETURNING balance_nanoton`, userID, amount.Amount).Scan(&balance); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + return domain.StarsBalance{}, err + } + _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) + VALUES($1,$2,$3,$4,$5,$6)`, userID, -amount.Amount, string(reason), nullableStarGiftPeerType(peer), nullableStarGiftPeerID(peer), date) + return domain.StarsBalance{UserID: userID, Balance: balance}, err + } + result := domain.StarsBalance{UserID: userID} + var current int64 + if err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(¤t, &result.Granted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + return domain.StarsBalance{}, err + } + if current < amount.Amount { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount.Amount).Scan(&result.Balance); err != nil { + return domain.StarsBalance{}, err + } + if err := insertStarsTxn(ctx, tx, userID, -amount.Amount, reason, peer, date, title, ""); err != nil { + return domain.StarsBalance{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) creditLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount, + reason domain.StarsTransactionReason, peer domain.Peer, date int, title string) error { + if amount.Currency == domain.StarGiftCurrencyTON { + if _, err := tx.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,false) + ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, userID, amount.Amount); err != nil { + return err + } + _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) + VALUES($1,$2,$3,$4,$5,$6)`, userID, amount.Amount, string(reason), nullableStarGiftPeerType(peer), nullableStarGiftPeerID(peer), date) + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,updated_at) VALUES($1,$2,now()) + ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now()`, userID, amount.Amount); err != nil { + return err + } + return insertStarsTxn(ctx, tx, userID, amount.Amount, reason, peer, date, title, "") +} + +// creditPeerLifecycleAmount credits marketplace proceeds to the actual gift +// owner. Channel Stars and TON are isolated local revenue ledgers; neither is +// redirected to the administrator who happened to execute the RPC. +func (s *StarGiftLifecycleStore) creditPeerLifecycleAmount(ctx context.Context, tx pgx.Tx, owner domain.Peer, + actorUserID int64, amount domain.StarGiftAmount, reason domain.StarsTransactionReason, counterparty domain.Peer, + giftID int64, date int, title string) (int64, int64, error) { + if !validLifecyclePeer(owner) || actorUserID <= 0 || !amount.Valid() || giftID <= 0 || date <= 0 { + return 0, 0, domain.ErrStarGiftResaleUnavailable + } + permille := s.market.StarsProceedsPermille + if amount.Currency == domain.StarGiftCurrencyTON { + permille = s.market.TONProceedsPermille + } + proceeds := amount.Amount/1000*int64(permille) + amount.Amount%1000*int64(permille)/1000 + commission := amount.Amount - proceeds + credited := amount + credited.Amount = proceeds + if owner.Type == domain.PeerTypeUser { + if proceeds > 0 { + if err := s.creditLifecycleAmount(ctx, tx, owner.ID, credited, reason, counterparty, date, title); err != nil { + return 0, 0, err + } + } + var balance int64 + if amount.Currency == domain.StarGiftCurrencyTON { + if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM ton_balances WHERE user_id=$1),0)`, owner.ID).Scan(&balance); err != nil { + return 0, 0, err + } + } else if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM stars_balances WHERE user_id=$1),0)`, owner.ID).Scan(&balance); err != nil { + return 0, 0, err + } + return balance, commission, nil + } + + var balance int64 + if amount.Currency == domain.StarGiftCurrencyTON { + if proceeds == 0 { + err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1),0)`, owner.ID).Scan(&balance) + return balance, commission, err + } + if err := tx.QueryRow(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() + RETURNING balance_nanoton`, owner.ID, proceeds).Scan(&balance); err != nil { + return 0, 0, err + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions + (channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, owner.ID, actorUserID, proceeds, string(reason), + string(counterparty.Type), counterparty.ID, giftID, date); err != nil { + return 0, 0, err + } + return balance, commission, nil + } + if proceeds == 0 { + err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, owner.ID).Scan(&balance) + return balance, commission, err + } + if err := tx.QueryRow(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) + ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now() + RETURNING balance`, owner.ID, proceeds).Scan(&balance); err != nil { + return 0, 0, err + } + if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions + (channel_id,actor_user_id,amount,reason,peer_type,peer_id,gift_id,date) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, owner.ID, actorUserID, proceeds, string(reason), + string(counterparty.Type), counterparty.ID, giftID, date); err != nil { + return 0, 0, err + } + return balance, commission, nil +} + +func (s *StarGiftLifecycleStore) loadTransferReplay(ctx context.Context, req domain.StarGiftTransferRequest, sent domain.SendPrivateTextResult) (domain.StarGiftTransferResult, error) { + var uniqueID, balance int64 + if err := s.db.QueryRow(ctx, `SELECT unique_gift_id,balance_after FROM star_gift_transfer_commands WHERE actor_user_id=$1 AND command_key=$2`, + req.ActorUserID, strings.TrimSpace(req.CommandKey)).Scan(&uniqueID, &balance); err != nil { + return domain.StarGiftTransferResult{}, err + } + unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) + if err != nil || !found { + return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable + } + uniqueCopy := unique + saved.Unique = &uniqueCopy + return domain.StarGiftTransferResult{Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.ActorUserID, Balance: balance}, Send: sent, Duplicate: true}, nil +} + +func savedStarGiftByUniqueID(ctx context.Context, db sqlcgen.DBTX, uniqueID int64) (domain.SavedStarGift, bool, error) { + row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id, + p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num, + p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at, + p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order, + COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i + JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[]) + FROM peer_star_gifts p WHERE p.unique_gift_id=$1`, uniqueID) + saved, err := scanSavedStarGift(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return saved, err == nil, err +} + +func updateStarGiftResaleProjection(ctx context.Context, tx pgx.Tx, giftID int64) error { + _, err := tx.Exec(ctx, `UPDATE star_gift_catalog c SET + availability_resale=(SELECT COUNT(*) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=c.gift_id), + resell_min_stars=COALESCE((SELECT MIN(l.amount) FROM star_gift_listings l JOIN unique_star_gifts u ON u.id=l.unique_gift_id WHERE u.gift_id=c.gift_id AND l.currency='XTR'),0), + updated_at=now() WHERE c.gift_id=$1`, giftID) + return err +} + +func (s *StarGiftLifecycleStore) SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error { + if userID <= 0 || channelID <= 0 { + return domain.ErrStarGiftOwnerInvalid + } + _, err := s.db.Exec(ctx, `INSERT INTO star_gift_notification_settings(user_id,channel_id,enabled) VALUES($1,$2,$3) +ON CONFLICT(user_id,channel_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_at=now()`, userID, channelID, enabled) + return err +} + +func (s *StarGiftLifecycleStore) RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) { + if req.UserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || expiresAt <= req.Date || strings.TrimSpace(provider) == "" || strings.TrimSpace(providerRequestID) == "" || strings.TrimSpace(url) == "" { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + err := withTx(ctx, s.db, "record star gift withdrawal", func(tx pgx.Tx) error { + saved, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref) + if err != nil { + return err + } + if saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanExportAt > req.Date { + return domain.ErrStarGiftTransferUnavailable + } + var existingID int64 + var existingStatus string + var existingExpires int + err = tx.QueryRow(ctx, `SELECT id,status,expires_at FROM star_gift_withdrawal_requests WHERE unique_gift_id=$1 FOR UPDATE`, saved.UniqueGiftID). + Scan(&existingID, &existingStatus, &existingExpires) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + if existingStatus == "completed" || existingStatus == "pending" && existingExpires > req.Date { + return nil + } + _, err = tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET provider=$2,provider_request_id=$3,url=$4, +status='pending',created_at=$5,expires_at=$6,completed_at=0 WHERE id=$1`, existingID, provider, providerRequestID, url, req.Date, expiresAt) + return err + } + _, err = tx.Exec(ctx, `INSERT INTO star_gift_withdrawal_requests(unique_gift_id,owner_user_id,provider,provider_request_id,url,created_at,expires_at) +VALUES($1,$2,$3,$4,$5,$6,$7)`, saved.UniqueGiftID, req.UserID, provider, providerRequestID, url, req.Date, expiresAt) + return err + }) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + // If an unexpired request already existed, return it instead of exposing a + // newly generated but unpersisted bearer URL. + saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return s.resolveStarGiftWithdrawalByUniqueID(ctx, saved.UniqueGiftID) +} + +func (s *StarGiftLifecycleStore) ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) { + providerRequestID = strings.TrimSpace(providerRequestID) + if providerRequestID == "" || len(providerRequestID) > 256 { + return domain.StarGiftWithdrawal{}, false, nil + } + var uniqueID int64 + if err := s.db.QueryRow(ctx, `SELECT unique_gift_id FROM star_gift_withdrawal_requests WHERE provider_request_id=$1`, providerRequestID).Scan(&uniqueID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftWithdrawal{}, false, nil + } + return domain.StarGiftWithdrawal{}, false, err + } + withdrawal, err := s.resolveStarGiftWithdrawalByUniqueID(ctx, uniqueID) + return withdrawal, err == nil, err +} + +func (s *StarGiftLifecycleStore) resolveStarGiftWithdrawalByUniqueID(ctx context.Context, uniqueID int64) (domain.StarGiftWithdrawal, error) { + var out domain.StarGiftWithdrawal + if err := s.db.QueryRow(ctx, `SELECT provider_request_id,url,expires_at,status FROM star_gift_withdrawal_requests WHERE unique_gift_id=$1`, uniqueID). + Scan(&out.ProviderRequestID, &out.URL, &out.ExpiresAt, &out.Status); err != nil { + return domain.StarGiftWithdrawal{}, err + } + gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + out.Gift = gift + return out, nil +} + +func (s *StarGiftLifecycleStore) CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) { + providerRequestID = strings.TrimSpace(providerRequestID) + if providerRequestID == "" || len(providerRequestID) > 256 || date <= 0 { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + expired := false + err := withTx(ctx, s.db, "complete star gift withdrawal", func(tx pgx.Tx) error { + var uniqueID, ownerUserID int64 + var status string + var expiresAt int + if err := tx.QueryRow(ctx, `SELECT unique_gift_id,owner_user_id,status,expires_at FROM star_gift_withdrawal_requests +WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ownerUserID, &status, &expiresAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftWithdrawalUnavailable + } + return err + } + if status == "completed" { + return nil + } + if status != "pending" || expiresAt <= date { + expired = true + _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='failed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date) + return err + } + saved, found, err := lockSavedStarGiftByUniqueID(ctx, tx, uniqueID) + if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}) || !saved.LifecycleStatus.Live() { + return domain.ErrStarGiftWithdrawalUnavailable + } + unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID) + if err != nil || !found || unique.Owner != saved.Owner || unique.Burned || unique.OwnerAddress != "" { + return domain.ErrStarGiftWithdrawalUnavailable + } + if err := s.refundPendingStarGiftOffers(ctx, tx, uniqueID, date, "gift exported"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM star_gift_listings WHERE unique_gift_id=$1`, uniqueID); err != nil { + return err + } + if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { + return err + } + ownerAddress := "telesrv-owner:" + providerRequestID + requestHash := sha256.Sum256([]byte(providerRequestID)) + giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8]) + if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL, +owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil { + return err + } + return updateStarGiftResaleProjection(ctx, tx, unique.GiftID) + }) + if err != nil { + return domain.StarGiftWithdrawal{}, err + } + if expired { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + withdrawal, found, err := s.ResolveStarGiftWithdrawal(ctx, providerRequestID) + if err != nil || !found { + return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable + } + return withdrawal, nil +} + +func (s *StarGiftLifecycleStore) TonBalance(ctx context.Context, userID int64) (int64, error) { + if userID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := withTx(ctx, s.db, "ensure internal ton grant", func(tx pgx.Tx) error { + var err error + balance, err = s.ensureTonGrantTx(ctx, tx, userID, int(time.Now().Unix())) + return err + }) + return balance, err +} + +func (s *StarGiftLifecycleStore) ensureTonGrantTx(ctx context.Context, tx pgx.Tx, userID int64, date int) (int64, error) { + if _, err := tx.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,0,false) +ON CONFLICT(user_id) DO NOTHING`, userID); err != nil { + return 0, err + } + var balance int64 + var granted bool + if err := tx.QueryRow(ctx, `SELECT balance_nanoton,granted FROM ton_balances WHERE user_id=$1 FOR UPDATE`, userID). + Scan(&balance, &granted); err != nil { + return 0, err + } + if granted { + return balance, nil + } + if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton+$2,granted=true,updated_at=now() +WHERE user_id=$1 RETURNING balance_nanoton`, userID, s.tonStartingGrant).Scan(&balance); err != nil { + return 0, err + } + if s.tonStartingGrant > 0 { + if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,date) +VALUES($1,$2,$3,$4)`, userID, s.tonStartingGrant, string(domain.StarsReasonGrant), date); err != nil { + return 0, err + } + } + return balance, nil +} + +func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if userID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + if _, err := s.TonBalance(ctx, userID); err != nil { + return domain.TonTransactionPage{}, err + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{userID, limit + 1} + where := "user_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,user_id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0), +amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.TonTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.TonTransaction, 0, limit+1) + for rows.Next() { + var item domain.TonTransaction + var peerType string + if err := rows.Scan(&item.ID, &item.UserID, &peerType, &item.Peer.ID, &item.GiftID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.TonTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.TonTransactionPage{}, err + } + page := domain.TonTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + if err := s.db.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1`, userID).Scan(&page.Balance); err != nil { + return domain.TonTransactionPage{}, err + } + return page, nil +} + +// Channel Stars/TON ledgers are revenue projections owned by the channel. They +// never receive a starting grant and are deliberately separate from the actor +// administrator's personal balances. +func (s *StarGiftLifecycleStore) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) { + if channelID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := s.db.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, channelID).Scan(&balance) + return balance, err +} + +func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.StarsTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{channelID, limit + 1} + where := "channel_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),amount,date,reason +FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.StarsTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.StarsTransaction, 0, limit+1) + for rows.Next() { + var item domain.StarsTransaction + var peerType string + if err := rows.Scan(&item.ID, &peerType, &item.Peer.ID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.StarsTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.StarsTransactionPage{}, err + } + page := domain.StarsTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + page.Balance, err = s.ChannelStarsBalance(ctx, channelID) + return page, err +} + +func (s *StarGiftLifecycleStore) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) { + if channelID <= 0 { + return 0, domain.ErrStarGiftOwnerInvalid + } + var balance int64 + err := s.db.QueryRow(ctx, `SELECT COALESCE((SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1),0)`, channelID).Scan(&balance) + return balance, err +} + +func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) { + if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes { + return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid + } + cursor, hasCursor := domain.DecodeStarsCursor(offset) + args := []any{channelID, limit + 1} + where := "channel_id=$1" + if hasCursor { + where += " AND id<$3" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),amount_nanoton,date,reason +FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...) + if err != nil { + return domain.TonTransactionPage{}, err + } + defer rows.Close() + items := make([]domain.TonTransaction, 0, limit+1) + for rows.Next() { + var item domain.TonTransaction + var peerType string + if err := rows.Scan(&item.ID, &peerType, &item.Peer.ID, &item.GiftID, &item.Amount, &item.Date, &item.Reason); err != nil { + return domain.TonTransactionPage{}, err + } + item.Peer.Type = domain.PeerType(peerType) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.TonTransactionPage{}, err + } + page := domain.TonTransactionPage{} + if len(items) > limit { + items = items[:limit] + page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID) + } + page.Transactions = items + page.Balance, err = s.ChannelTonBalance(ctx, channelID) + return page, err +} + +func lifecycleCommandRandomID(parts ...any) int64 { + sum := sha256.Sum256([]byte(fmt.Sprint(parts...))) + id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) + if id == 0 { + return 1 + } + return id +} + +func validLifecyclePeer(peer domain.Peer) bool { + return peer.ID > 0 && (peer.Type == domain.PeerTypeUser || peer.Type == domain.PeerTypeChannel) +} + +func sortedUniqueInt64(values []int64) []int64 { + out := append([]int64(nil), values...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +var _ store.StarGiftLifecycleStore = (*StarGiftLifecycleStore)(nil) diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go new file mode 100644 index 00000000..8bff57f0 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -0,0 +1,865 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + buyer := createTestUser(t, ctx, users, "+1881"+suffix+"01", "GiftBuyer", "") + owner := createTestUser(t, ctx, users, "+1881"+suffix+"02", "GiftOwner", "") + offerBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"03", "OfferBuyer", "") + resaleBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"04", "ResaleBuyer", "") + loser := createTestUser(t, ctx, users, "+1881"+suffix+"05", "AuctionLoser", "") + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + stars := NewStarsStore(pool) + for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} { + if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil { + t.Fatalf("grant stars to %d: %v", user.ID, err) + } + } + + gifts := NewStarGiftStore(pool) + baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Lifecycle " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, + Document: collectibleTestDocument(baseDocumentID, "lifecycle.tgs"), + Blob: collectibleTestBlob(baseDocumentID, "lifecycle"), Animation: collectibleTestAnimation("lifecycle.tgs"), + Actor: "integration", CommandID: "lifecycle-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create lifecycle catalog: %v", err) + } + if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "life-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{ + {Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")}, + {Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")}, + }, + Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}}, + Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, + Actor: "integration", CommandID: "lifecycle-pool-" + suffix, + }); err != nil { + t.Fatalf("publish lifecycle pool: %v", err) + } + + messages := NewMessageStore(pool) + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{ + StarsProceedsPermille: 900, TONProceedsPermille: 900, + })) + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500, + })) + + purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, CommandKey: "purchase-" + suffix, Date: now, Message: "hello"}) + purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq) + if err != nil { + t.Fatalf("purchase gift: %v", err) + } + if purchased.Saved.ID <= 0 || purchased.Saved.MsgID <= 0 || purchased.Saved.PrepaidUpgradeHash == "" || purchased.Balance.Balance != 9950 { + t.Fatalf("purchase result = %+v", purchased) + } + ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift + if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade || + ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 { + t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction) + } + replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq) + if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 || + replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID || + replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID { + t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err) + } + + target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash) + if err != nil || target.ID != purchased.Saved.ID || price != 100 { + t.Fatalf("prepaid target = %+v price %d err %v", target, price, err) + } + prepaid, err := lifecycle.PrepayStarGiftUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{ + PayerUserID: buyer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash, + ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1, + }) + if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 { + t.Fatalf("prepay upgrade = %+v err %v", prepaid, err) + } + upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, + RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2, + }) + if err != nil { + t.Fatalf("upgrade prepaid gift: %v", err) + } + if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 || + upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails { + t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique) + } + upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) + if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) || + ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID || + ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade { + t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit) + } + dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{ + UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, + ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3, + }) + if err != nil || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 || dropped.Balance.Balance != 9975 { + t.Fatalf("drop original details = %+v err %v", dropped, err) + } + + // Expiry is driven by the background sweep, refunds exactly once and emits a + // durable declined/expired service message even when no user opens the offer. + expiring, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300}, + Duration: 120, RandomID: 22001, Date: now + 10, + }) + if err != nil || expiring.Balance.Balance != 9700 { + t.Fatalf("send expiring offer = %+v err %v", expiring, err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+131, 1000); err != nil { + t.Fatalf("sweep expired offer: %v", err) + } + var expiredStatus string + var resolutionNotified bool + if err := pool.QueryRow(ctx, `SELECT status,resolution_notified FROM star_gift_offers WHERE id=$1`, expiring.Offer.ID). + Scan(&expiredStatus, &resolutionNotified); err != nil || expiredStatus != "expired" || !resolutionNotified { + t.Fatalf("expired offer state = %q notified %v err %v", expiredStatus, resolutionNotified, err) + } + if balance, err := stars.GetBalance(ctx, offerBuyer.ID); err != nil || balance.Balance != 10000 { + t.Fatalf("expired offer refund balance = %+v err %v", balance, err) + } + + // TON offers use the same durable offer state machine, but only mutate the + // internal telesrv TON ledger. Idempotent replay must report that ledger's + // balance instead of accidentally projecting the buyer's Stars balance. + tonOfferReq := domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 300}, + Duration: 120, RandomID: 22003, Date: now + 132} + tonOffer, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq) + if err != nil || tonOffer.Balance.Balance != 999700 { + t.Fatalf("send TON offer = %+v err %v", tonOffer, err) + } + tonOfferReplay, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq) + if err != nil || !tonOfferReplay.Duplicate || tonOfferReplay.Balance.Balance != 999700 { + t.Fatalf("replay TON offer = %+v err %v", tonOfferReplay, err) + } + if _, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{ + OwnerUserID: owner.ID, OfferMsgID: tonOffer.Offer.OfferMsgID, Decline: true, Date: now + 133, + }); err != nil { + t.Fatalf("decline TON offer: %v", err) + } + if balance, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || balance != 1_000_000 { + t.Fatalf("declined TON offer refund balance = %d err %v", balance, err) + } + + acceptedOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID, + Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300}, + Duration: 120, RandomID: 22002, Date: now + 140, + }) + if err != nil { + t.Fatalf("send accepted offer: %v", err) + } + accepted, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{ + OwnerUserID: owner.ID, OfferMsgID: acceptedOffer.Offer.OfferMsgID, Date: now + 141, + }) + if err != nil || accepted.Offer.Status != "accepted" || accepted.Unique.Owner.ID != offerBuyer.ID || accepted.Saved.MsgID <= 0 { + t.Fatalf("accept offer = %+v err %v", accepted, err) + } + if balance, err := stars.GetBalance(ctx, owner.ID); err != nil || balance.Balance != 10245 { + t.Fatalf("offer seller balance = %+v err %v", balance, err) + } + var offerCommission int64 + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, + fmt.Sprintf("offer:%d", acceptedOffer.Offer.ID)).Scan(&offerCommission); err != nil || offerCommission != 30 { + t.Fatalf("accepted Stars offer commission = %d err %v", offerCommission, err) + } + + listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: offerBuyer.ID, + Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: offerBuyer.ID}, MsgID: accepted.Saved.MsgID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 142, + }) + if err != nil || listed.ResellAmount == nil || listed.ResellAmount.Currency != domain.StarGiftCurrencyTON { + t.Fatalf("TON listing = %+v err %v", listed, err) + } + tonBefore, err := lifecycle.TonBalance(ctx, resaleBuyer.ID) + if err != nil || tonBefore != 1_000_000 { + t.Fatalf("resale buyer TON grant = %d err %v", tonBefore, err) + } + resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{ + BuyerUserID: resaleBuyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, + Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 11004, + CommandKey: "resale-" + suffix, Date: now + 143, + }) + if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 { + t.Fatalf("TON resale = %+v err %v", resold, err) + } + selected, valid := domain.CollectibleEmojiStatus(resold.Unique) + if !valid { + t.Fatalf("resold collectible cannot project emoji status: %+v", resold.Unique) + } + if _, err := users.UpdateEmojiStatus(ctx, resaleBuyer.ID, domain.UserEmojiStatus{ + DocumentID: selected.DocumentID, + Collectible: selected, + }); err != nil { + t.Fatalf("wear resold collectible: %v", err) + } + updateEvents := NewUpdateEventStore(pool) + statusPtsBeforeTransfer, err := updateEvents.MaxContiguousPts(ctx, resaleBuyer.ID) + if err != nil { + t.Fatalf("emoji status pts before transfer: %v", err) + } + if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 { + t.Fatalf("TON seller local balance = %d err %v", sellerTON, err) + } + var resaleCommission int64 + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, "resale-"+suffix). + Scan(&resaleCommission); err != nil || resaleCommission != 100 { + t.Fatalf("TON resale commission = %d err %v", resaleCommission, err) + } + tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20) + if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 { + t.Fatalf("TON ledger page = %+v err %v", tonPage, err) + } + + transferred, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ActorUserID: resaleBuyer.ID, + Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, MsgID: resold.Saved.MsgID}, + To: ownerPeer, ChargeStars: 25, FormID: 11005, CommandKey: "transfer-back-" + suffix, Date: now + 144, + }) + if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 { + t.Fatalf("paid transfer = %+v err %v", transferred, err) + } + clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID) + if err != nil || !found || !clearedUser.EmojiStatus().Empty() { + t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err) + } + statusEvents, err := updateEvents.ListAfter(ctx, resaleBuyer.ID, statusPtsBeforeTransfer, 20) + if err != nil { + t.Fatalf("load collectible invalidation event: %v", err) + } + var clearEvent domain.UpdateEvent + for _, event := range statusEvents { + if event.Type == domain.UpdateEventUserEmojiStatus { + clearEvent = event + break + } + } + if clearEvent.Pts == 0 || !clearEvent.EmojiStatus.Empty() || clearEvent.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}) { + t.Fatalf("collectible invalidation event = %+v", clearEvent) + } + var clearOutboxCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox +WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBuyer.ID, clearEvent.Pts).Scan(&clearOutboxCount); err != nil || clearOutboxCount != 1 { + t.Fatalf("collectible invalidation outbox count=%d err=%v, want 1", clearOutboxCount, err) + } + + // A second prepaid collectible makes craft chance exactly 1000‰. Success + // preserves the first aggregate as crafted and burns the other input. The + // fresh payment intent must create another gift even though buyer, owner and + // catalog gift are identical to the first purchase. + secondPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-second-" + suffix, Date: now + 145}) + secondPurchase, err := lifecycle.PurchaseStarGift(ctx, secondPurchaseReq) + if err != nil { + t.Fatalf("purchase second prepaid gift: %v", err) + } + prepaidAction := secondPurchase.Send.RecipientMessage.Media.ServiceAction.StarGift + if prepaidAction == nil || !prepaidAction.PrepaidUpgrade || prepaidAction.UpgradePriceStars != 100 || prepaidAction.UpgradeStars != 100 { + t.Fatalf("prepaid purchase action lost price/entitlement split: %+v", prepaidAction) + } + secondUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: secondPurchase.Saved.MsgID}, RequirePrepaid: true, + CommandKey: "upgrade-second-" + suffix, Date: now + 146, + }) + if err != nil { + t.Fatalf("upgrade second prepaid gift: %v", err) + } + listedForCraft, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 146, + }) + if err != nil || listedForCraft.ResellAmount == nil || listedForCraft.ResellAmount.Amount != 125 { + t.Fatalf("list craft input = %+v err %v", listedForCraft, err) + } + loserBalanceBeforeOffer, err := stars.GetBalance(ctx, loser.ID) + if err != nil { + t.Fatalf("craft offer buyer balance: %v", err) + } + pendingCraftOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: loser.ID, + Owner: ownerPeer, Slug: transferred.Unique.Slug, + Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, + Duration: 120, RandomID: 22003, Date: now + 146, + }) + if err != nil || pendingCraftOffer.Offer.Status != "pending" { + t.Fatalf("pending craft offer = %+v err %v", pendingCraftOffer, err) + } + resolvedCraftIDs, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }) + if err != nil || len(resolvedCraftIDs) != 2 || resolvedCraftIDs[0] != transferred.Saved.ID || resolvedCraftIDs[1] != secondUpgrade.Saved.ID { + t.Fatalf("resolve mixed craft refs = %v err %v", resolvedCraftIDs, err) + } + if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }); !errors.Is(err, domain.ErrStarGiftNotFound) { + t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err) + } + if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{ + Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID, + }); err != nil || found { + t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err) + } + if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("duplicate official identities err = %v", err) + } + crafted, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + // TDesktop sends collectibles without a manage id as the official + // inputSavedStarGiftSlug alias. + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }, CommandKey: "craft-" + suffix, Date: now + 147, + }) + if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 { + t.Fatalf("craft result = %+v err %v", crafted, err) + } + craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID) + craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit) + burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID) + burnedInputAction := starGiftUniqueActionFromEdit(burnedInputEdit) + if craftedInputAction == nil || !craftedInputAction.Gift.Crafted || craftedInputAction.Gift.Burned || + craftedInputAction.Gift.CraftChancePermille != 0 || !craftedInputAction.Saved || craftedInputAction.CanCraftAt != 0 { + t.Fatalf("crafted input message projection = %+v", craftedInputAction) + } + if burnedInputAction == nil || !burnedInputAction.Gift.Burned || burnedInputAction.Gift.CraftChancePermille != 0 || + burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 { + t.Fatalf("burned input message projection = %+v", burnedInputAction) + } + craftReq := domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{ + {Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, + {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, + }, CommandKey: "craft-" + suffix, Date: now + 147, + } + craftedReplay, err := lifecycle.CraftStarGift(ctx, craftReq) + if err != nil || !craftedReplay.Duplicate || !craftedReplay.Success || craftedReplay.Gift == nil || + craftedReplay.Send.RecipientMessage.ID != crafted.Send.RecipientMessage.ID || + craftedSourceEditForUserAndGift(craftedReplay, owner.ID, transferred.Unique.ID).Event.Pts != craftedInputEdit.Event.Pts || + craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts { + t.Fatalf("craft success replay = %+v err %v", craftedReplay, err) + } + var craftListings, resaleAvailability int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, + []int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 { + t.Fatalf("craft input listings = %d err %v", craftListings, err) + } + if err := pool.QueryRow(ctx, `SELECT availability_resale FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&resaleAvailability); err != nil || resaleAvailability != 0 { + t.Fatalf("craft resale projection = %d err %v", resaleAvailability, err) + } + var craftOfferStatus string + if err := pool.QueryRow(ctx, `SELECT status FROM star_gift_offers WHERE id=$1`, pendingCraftOffer.Offer.ID).Scan(&craftOfferStatus); err != nil || craftOfferStatus != "cancelled" { + t.Fatalf("craft offer status = %q err %v", craftOfferStatus, err) + } + loserBalanceAfterCraft, err := stars.GetBalance(ctx, loser.ID) + if err != nil || loserBalanceAfterCraft.Balance != loserBalanceBeforeOffer.Balance { + t.Fatalf("craft offer refund balance = %+v err %v, want %d", loserBalanceAfterCraft, err, loserBalanceBeforeOffer.Balance) + } + var secondStatus string + if err := pool.QueryRow(ctx, `SELECT lifecycle_status FROM peer_star_gifts WHERE id=$1`, secondUpgrade.Saved.ID).Scan(&secondStatus); err != nil || secondStatus != "burned" { + t.Fatalf("second craft input status = %q err %v", secondStatus, err) + } + + // A failed draw is just as terminal as success: the input aggregate and both + // users' message snapshots are burned in the outcome transaction. An exact + // retry replays the receipt, while a fresh command cannot consume it again. + thirdPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-third-" + suffix, Date: now + 148}) + thirdPurchase, err := lifecycle.PurchaseStarGift(ctx, thirdPurchaseReq) + if err != nil { + t.Fatalf("purchase third prepaid gift: %v", err) + } + thirdUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: thirdPurchase.Saved.MsgID}, RequirePrepaid: true, + CommandKey: "upgrade-third-" + suffix, Date: now + 149, + }) + if err != nil { + t.Fatalf("upgrade third prepaid gift: %v", err) + } + failingLifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, + WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}), + WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil })) + failureReq := domain.StarGiftCraftRequest{UserID: owner.ID, + Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}}, + CommandKey: "craft-fail-" + suffix, Date: now + 150, + } + failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq) + if err != nil || failedCraft.Success || failedCraft.Chance != 500 || failedCraft.Gift != nil { + t.Fatalf("craft failure result = %+v err %v", failedCraft, err) + } + failedInputEdit := craftedSourceEditForUserAndGift(failedCraft, owner.ID, thirdUpgrade.Unique.ID) + failedInputAction := starGiftUniqueActionFromEdit(failedInputEdit) + if failedInputAction == nil || !failedInputAction.Gift.Burned || failedInputAction.Gift.CraftChancePermille != 0 || + failedInputAction.Gift.OfferMinStars != 0 || failedInputAction.Saved || failedInputAction.CanCraftAt != 0 { + t.Fatalf("failed craft message projection = %+v", failedInputAction) + } + var failedLifecycle string + var failedUnsaved bool + var failedTransferStars int64 + var failedCanExportAt, failedCanTransferAt, failedCanResellAt, failedCanCraftAt int + var failedDropStars int64 + if err := pool.QueryRow(ctx, `SELECT lifecycle_status,unsaved,transfer_stars,can_export_at,can_transfer_at, +can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHERE id=$1`, thirdUpgrade.Saved.ID). + Scan(&failedLifecycle, &failedUnsaved, &failedTransferStars, &failedCanExportAt, &failedCanTransferAt, + &failedCanResellAt, &failedDropStars, &failedCanCraftAt); err != nil || failedLifecycle != "burned" || !failedUnsaved || + failedTransferStars != 0 || failedCanExportAt != 0 || failedCanTransferAt != 0 || failedCanResellAt != 0 || + failedDropStars != 0 || failedCanCraftAt != 0 { + t.Fatalf("failed craft saved aggregate = status %q unsaved %v transfer %d export %d transfer_at %d resale %d drop %d craft %d err %v", + failedLifecycle, failedUnsaved, failedTransferStars, failedCanExportAt, failedCanTransferAt, + failedCanResellAt, failedDropStars, failedCanCraftAt, err) + } + var failedBurned bool + var failedChance, failedOfferMin int + if err := pool.QueryRow(ctx, `SELECT burned,craft_chance_permille,offer_min_stars FROM unique_star_gifts WHERE id=$1`, thirdUpgrade.Unique.ID). + Scan(&failedBurned, &failedChance, &failedOfferMin); err != nil || !failedBurned || failedChance != 0 || failedOfferMin != 0 { + t.Fatalf("failed craft unique aggregate = burned %v chance %d offer %d err %v", failedBurned, failedChance, failedOfferMin, err) + } + failedReplay, err := failingLifecycle.CraftStarGift(ctx, failureReq) + if err != nil || !failedReplay.Duplicate || failedReplay.Success || failedReplay.Chance != failedCraft.Chance || + craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts { + t.Fatalf("craft failure replay = %+v err %v", failedReplay, err) + } + invalidRetry := failureReq + invalidRetry.CommandKey = "craft-fail-new-command-" + suffix + if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { + t.Fatalf("fresh command reused burned craft input: %v", err) + } + craftCandidates, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 20) + if err != nil || craftCandidates.Count != 0 || len(craftCandidates.Gifts) != 0 { + t.Fatalf("terminal craft inputs remained candidates: %+v err %v", craftCandidates, err) + } + + withdrawalReq := domain.StarGiftWithdrawalRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, Date: now + 151} + recorded, err := lifecycle.RecordStarGiftWithdrawal(ctx, withdrawalReq, "local", "withdraw-"+suffix, + "https://telesrv.invalid/gift-withdrawal/"+suffix, now+748) + if err != nil || recorded.Status != "pending" { + t.Fatalf("record local withdrawal = %+v err %v", recorded, err) + } + completed, err := lifecycle.CompleteStarGiftWithdrawal(ctx, recorded.ProviderRequestID, now+152) + if err != nil || completed.Status != "completed" || completed.Gift.OwnerAddress == "" || completed.Gift.GiftAddress == "" { + t.Fatalf("complete local withdrawal = %+v err %v", completed, err) + } + + // Auction winner reservation is consumed; the unreachable lower bid is + // refunded atomically. Award delivery is durable and includes gift_num. + auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true, + AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10, + AuctionSlug: "auction-" + suffix, + Document: collectibleTestDocument(baseDocumentID+100, "auction.tgs"), + Blob: collectibleTestBlob(baseDocumentID+100, "auction"), Animation: collectibleTestAnimation("auction.tgs"), + Actor: "integration", CommandID: "auction-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create auction catalog: %v", err) + } + winnerState, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: resaleBuyer.ID, + GiftID: auctionEntry.Gift.ID, Peer: ownerPeer, BidAmount: 200, FormID: 12001, Date: now, Message: "winner"}) + if err != nil || winnerState.UserState.BidAmount != 200 { + t.Fatalf("winner bid state = %+v err %v", winnerState, err) + } + if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: loser.ID, + GiftID: auctionEntry.Gift.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: loser.ID}, + BidAmount: 150, FormID: 12002, Date: now + 1}); err != nil { + t.Fatalf("loser bid: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+2); err != nil { + t.Fatalf("make auction round due: %v", err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+2, 1000); err != nil { + t.Fatalf("settle auction sweep: %v", err) + } + acquired, err := lifecycle.StarGiftAuctionAcquired(ctx, resaleBuyer.ID, auctionEntry.Gift.ID) + if err != nil || len(acquired) != 1 || acquired[0].GiftNum != 1 || acquired[0].BidAmount != 200 { + t.Fatalf("auction acquired = %+v err %v", acquired, err) + } + if loserBalance, err := stars.GetBalance(ctx, loser.ID); err != nil || loserBalance.Balance != 10000 { + t.Fatalf("auction loser refund = %+v err %v", loserBalance, err) + } + var auctionSavedCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE gift_id=$1 AND gift_num=1 AND convert_stars=0`, auctionEntry.Gift.ID). + Scan(&auctionSavedCount); err != nil || auctionSavedCount != 1 { + t.Fatalf("auction saved award count = %d err %v", auctionSavedCount, err) + } + +} + +func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "") + if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil { + t.Fatalf("grant actor stars: %v", err) + } + created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now, + }) + if err != nil { + t.Fatalf("create gift channel: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now, + }) + if err != nil { + t.Fatalf("create target gift channel: %v", err) + } + targetChannelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdTarget.Channel.ID} + gifts := NewStarGiftStore(pool) + baseDocumentID := (time.Now().UnixNano() & 0x7ffffffffffff000) + 500 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Channel Gift " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, Limited: true, + AvailabilityTotal: 5, AvailabilityRemains: 5, + Document: collectibleTestDocument(baseDocumentID, "channel-gift.tgs"), Blob: collectibleTestBlob(baseDocumentID, "channel-gift"), + Animation: collectibleTestAnimation("channel-gift.tgs"), Actor: "integration", CommandID: "channel-gift-" + suffix, + }) + if err != nil { + t.Fatalf("create channel gift catalog: %v", err) + } + if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}}, + Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}}, + Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, + Actor: "integration", CommandID: "channel-gift-pool-" + suffix, + }); err != nil { + t.Fatalf("publish channel gift pool: %v", err) + } + messages := NewMessageStore(pool) + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{ + StarsProceedsPermille: 900, TONProceedsPermille: 900, + })) + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500, + })) + channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1}) + purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq) + if err != nil || purchased.Saved.SavedID <= 0 || purchased.Balance.Balance != 9950 { + t.Fatalf("atomic channel purchase = %+v err %v", purchased, err) + } + var regularLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 { + t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err) + } + var channelPrice string + var channelPrepaidAmount any + if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}' +FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='send_message' ORDER BY id DESC LIMIT 1`, created.Channel.ID). + Scan(&channelPrice, &channelPrepaidAmount); err != nil || channelPrice != "100" || channelPrepaidAmount != nil { + t.Fatalf("channel ordinary action price=%q prepaid=%v err=%v", channelPrice, channelPrepaidAmount, err) + } + if replay, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq); err != nil || !replay.Duplicate { + t.Fatalf("channel purchase replay = %+v err %v", replay, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 { + t.Fatalf("channel replay duplicated admin log count=%d err %v", regularLogs, err) + } + + converted, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 2}) + if err != nil || !converted.Saved.Converted || converted.OwnerBalance != 20 { + t.Fatalf("atomic channel conversion = %+v err %v", converted, err) + } + var channelBalance, conversionRows, conversionTxns int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 { + t.Fatalf("channel conversion balance = %d err %v", channelBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_conversions WHERE saved_gift_id=$1`, purchased.Saved.ID).Scan(&conversionRows); err != nil || conversionRows != 1 { + t.Fatalf("channel conversion command rows = %d err %v", conversionRows, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_stars_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, entry.Gift.ID).Scan(&conversionTxns); err != nil || conversionTxns != 1 { + t.Fatalf("channel conversion transactions = %d err %v", conversionTxns, err) + } + if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 { + t.Fatalf("channel stars balance projection = %d err %v", balance, err) + } + starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20) + if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 || + starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift { + t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err) + } + if _, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 3}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) { + t.Fatalf("repeated channel conversion err = %v, want already converted", err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 { + t.Fatalf("channel balance after replay = %d err %v", channelBalance, err) + } + + // A third party may prepay the upgrade entitlement of a channel-owned gift. + // The payer's personal Stars and the channel saved-gift entitlement commit + // together; the payment is also visible in channel Recent Actions. + channelPrepayTargetReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, CommandKey: "channel-prepay-target-" + suffix, Date: now + 4}) + channelPrepayTarget, err := lifecycle.PurchaseStarGift(ctx, channelPrepayTargetReq) + if err != nil || channelPrepayTarget.Saved.PrepaidUpgradeHash == "" { + t.Fatalf("channel prepay target purchase = %+v err %v", channelPrepayTarget, err) + } + prepayTarget, prepayPrice, err := lifecycle.PrepaidUpgradeTarget(ctx, channelPeer, channelPrepayTarget.Saved.PrepaidUpgradeHash) + if err != nil || prepayTarget.ID != channelPrepayTarget.Saved.ID || prepayPrice != 100 { + t.Fatalf("channel prepay target = %+v price=%d err=%v", prepayTarget, prepayPrice, err) + } + channelPrepayReq := domain.StarGiftPrepaidUpgradeRequest{ + PayerUserID: actor.ID, Owner: channelPeer, Hash: channelPrepayTarget.Saved.PrepaidUpgradeHash, + ChargeStars: 100, FormID: 21006, CommandKey: "channel-prepay-" + suffix, Date: now + 4, + } + channelPrepay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq) + if err != nil || channelPrepay.Saved.PrepaidUpgradeStars != 100 || channelPrepay.Saved.PrepaidUpgradeHash != "" || + channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID { + t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err) + } + var prepayLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 { + t.Fatalf("channel prepaid upgrade admin logs = %d err %v", prepayLogs, err) + } + channelPrepayReplay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq) + if err != nil || !channelPrepayReplay.Duplicate || channelPrepayReplay.Saved.ID != channelPrepay.Saved.ID { + t.Fatalf("channel prepaid entitlement replay = %+v err %v", channelPrepayReplay, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 { + t.Fatalf("channel prepaid upgrade replay logs = %d err %v", prepayLogs, err) + } + + var ptsBeforeUpgrade int + if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsBeforeUpgrade); err != nil { + t.Fatal(err) + } + prepaidPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer, + GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "channel-prepaid-purchase-" + suffix, Date: now + 4}) + prepaidPurchase, err := lifecycle.PurchaseStarGift(ctx, prepaidPurchaseReq) + if err != nil || prepaidPurchase.Saved.PrepaidUpgradeStars != 100 || prepaidPurchase.Saved.SavedID <= 0 { + t.Fatalf("channel prepaid gift purchase = %+v err %v", prepaidPurchase, err) + } + upgradeReq := domain.StarGiftUpgradeRequest{UserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: prepaidPurchase.Saved.SavedID}, RequirePrepaid: true, + KeepOriginalDetails: true, CommandKey: "channel-upgrade-" + suffix, Date: now + 5, + } + upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq) + if err != nil || upgraded.Saved.Owner != channelPeer || upgraded.Unique.Owner != channelPeer || + upgraded.Saved.SavedID != prepaidPurchase.Saved.SavedID || upgraded.Send.RecipientMessage.OwnerUserID != actor.ID { + t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err) + } + action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique + if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer || + action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 { + t.Fatalf("channel upgrade service action = %+v", action) + } + var ptsAfterUpgrade int + if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade { + t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err) + } + var upgradeLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 { + t.Fatalf("channel upgrade admin logs = %d err %v", upgradeLogs, err) + } + replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq) + if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID { + t.Fatalf("channel upgrade replay = %+v err %v", replayedUpgrade, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 { + t.Fatalf("channel upgrade replay admin logs = %d err %v", upgradeLogs, err) + } + dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{ + UserID: actor.ID, Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID}, + ChargeStars: 25, FormID: 21007, CommandKey: "channel-drop-details-" + suffix, Date: now + 6, + }) + if err != nil || dropped.Saved.Owner != channelPeer || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 { + t.Fatalf("channel drop original details = %+v err %v", dropped, err) + } + + listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID}, + Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 6, + }) + if err != nil || listed.ResellAmount == nil || listed.Owner != channelPeer { + t.Fatalf("list channel collectible = %+v err %v", listed, err) + } + if balance, err := lifecycle.TonBalance(ctx, actor.ID); err != nil || balance != 1_000_000 { + t.Fatalf("channel resale buyer local TON grant = %d err %v", balance, err) + } + resaleReq := domain.StarGiftResalePurchaseRequest{BuyerUserID: actor.ID, Slug: listed.Slug, To: targetChannelPeer, + Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 21004, + CommandKey: "channel-to-channel-resale-" + suffix, Date: now + 7, + } + resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq) + if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer || + resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 { + t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err) + } + var channelTON, channelTONTxns, targetResaleLogs, commission int64 + if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 { + t.Fatalf("channel local TON proceeds = %d err %v", channelTON, err) + } + if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 { + t.Fatalf("channel ton balance projection = %d err %v", balance, err) + } + tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20) + if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 || + tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale { + t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_ton_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, listed.ID).Scan(&channelTONTxns); err != nil || channelTONTxns != 1 { + t.Fatalf("channel local TON transactions = %d err %v", channelTONTxns, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, + createdTarget.Channel.ID).Scan(&targetResaleLogs); err != nil || targetResaleLogs != 1 { + t.Fatalf("target channel resale admin logs = %d err %v", targetResaleLogs, err) + } + if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, resaleReq.CommandKey).Scan(&commission); err != nil || commission != 100 { + t.Fatalf("channel TON resale commission = %d err %v", commission, err) + } + resaleReplay, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq) + if err != nil || !resaleReplay.Duplicate || resaleReplay.Unique.ID != resold.Unique.ID { + t.Fatalf("channel resale replay = %+v err %v", resaleReplay, err) + } + if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 { + t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err) + } + + var remainsBefore int + if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil { + t.Fatal(err) + } + balanceBefore, _ := NewStarsStore(pool).GetBalance(ctx, actor.ID) + invalidChannelReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, + To: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID + 999999}, GiftID: entry.Gift.ID, + CommandKey: "invalid-channel-purchase-" + suffix, Date: now + 2}) + _, err = lifecycle.PurchaseStarGift(ctx, invalidChannelReq) + if err == nil { + t.Fatal("purchase to missing channel unexpectedly succeeded") + } + var remainsAfter int + if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsAfter); err != nil || remainsAfter != remainsBefore { + t.Fatalf("inventory after rolled-back channel purchase = %d want %d err %v", remainsAfter, remainsBefore, err) + } + if balanceAfter, err := NewStarsStore(pool).GetBalance(ctx, actor.ID); err != nil || balanceAfter.Balance != balanceBefore.Balance { + t.Fatalf("balance after rolled-back channel purchase = %+v want %+v err %v", balanceAfter, balanceBefore, err) + } + + auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Channel Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true, + AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10, + AuctionSlug: "channel-auction-" + suffix, + Document: collectibleTestDocument(baseDocumentID+100, "channel-auction.tgs"), Blob: collectibleTestBlob(baseDocumentID+100, "channel-auction"), + Animation: collectibleTestAnimation("channel-auction.tgs"), Actor: "integration", CommandID: "channel-auction-" + suffix, + }) + if err != nil { + t.Fatalf("create channel auction: %v", err) + } + if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: actor.ID, + GiftID: auctionEntry.Gift.ID, Peer: channelPeer, BidAmount: 100, FormID: 22001, Date: now + 3, + }); err != nil { + t.Fatalf("bid channel auction: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+4); err != nil { + t.Fatal(err) + } + if err := lifecycle.SweepStarGiftLifecycle(ctx, now+4, 1000); err != nil { + t.Fatalf("settle channel auction: %v", err) + } + var awardSavedID int64 + if err := pool.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE gift_id=$1`, auctionEntry.Gift.ID).Scan(&awardSavedID); err != nil || awardSavedID <= 0 { + t.Fatalf("channel auction saved id = %d err %v", awardSavedID, err) + } + var awardLogs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events +WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channel.ID).Scan(&awardLogs); err != nil || awardLogs != 1 { + t.Fatalf("channel auction admin logs = %d err %v", awardLogs, err) + } +} + +func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore, + req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest { + t.Helper() + var revisionID int64 + if err := lifecycle.db.QueryRow(ctx, `SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1`, req.GiftID).Scan(&revisionID); err != nil { + t.Fatalf("load active gift revision: %v", err) + } + gift, found, err := NewStarGiftStore(lifecycle.db).CatalogRevision(ctx, revisionID) + if err != nil || !found { + t.Fatalf("load gift revision %d: found=%v err=%v", revisionID, found, err) + } + req.RevisionID = gift.RevisionID + req.ChargeStars = gift.Stars + if req.IncludeUpgrade { + req.ChargeStars += gift.UpgradeStars + } + issued, err := lifecycle.IssueStarGiftPurchaseForm(ctx, domain.StarGiftPurchaseForm{ + BuyerUserID: req.BuyerUserID, To: req.To, GiftID: req.GiftID, RevisionID: req.RevisionID, + IncludeUpgrade: req.IncludeUpgrade, HideName: req.HideName, Message: req.Message, ChargeStars: req.ChargeStars, + IssuedAt: req.Date, ExpiresAt: req.Date + 600, + }) + if err != nil { + t.Fatalf("issue purchase form: %v", err) + } + req.FormID = issued.FormID + return req +} + +func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser { + for _, edit := range result.SourceEdits { + if edit.UserID != userID { + continue + } + action := starGiftUniqueActionFromEdit(edit) + if action != nil && action.Gift.ID == uniqueGiftID { + return edit + } + } + return domain.EditedMessageForUser{UserID: userID} +} + +func starGiftUniqueActionFromEdit(edit domain.EditedMessageForUser) *domain.MessageStarGiftUniqueAction { + if edit.Message.Media == nil || edit.Message.Media.ServiceAction == nil { + return nil + } + return edit.Message.Media.ServiceAction.StarGiftUnique +} diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go new file mode 100644 index 00000000..93f42293 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -0,0 +1,20 @@ +package postgres + +import ( + "os" + "testing" +) + +func TestStarGiftLifecycleMigrationsApply(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + status, err := MigrateAndStatus(dsn) + if err != nil { + t.Fatalf("migrate star gift lifecycle schema: %v", err) + } + if status.Dirty || status.Empty || status.Version != 121 { + t.Fatalf("migration status = %+v, want clean version 121", status) + } +} diff --git a/internal/store/postgres/star_gift_official_import_integration_test.go b/internal/store/postgres/star_gift_official_import_integration_test.go new file mode 100644 index 00000000..e4baffc9 --- /dev/null +++ b/internal/store/postgres/star_gift_official_import_integration_test.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "errors" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + store := NewStarGiftStore(pool) + baseID := time.Now().UnixNano() & 0x7ffffffffffff000 + manifestSHA := make([]byte, 32) + for i := range manifestSHA { + manifestSHA[i] = 0x5a + } + attribute := func(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute { + value := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 918} + if kind == domain.StarGiftCollectibleBackdrop { + value.BackdropID = 0 + value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4 + return value + } + if kind == domain.StarGiftCollectiblePattern { + value.Document = collectibleTestPatternDocumentPtr(id, name+".tgs") + } else { + value.Document = collectibleTestDocumentPtr(id, name+".tgs") + } + value.Blob = collectibleTestBlobPtr(id, name) + value.Animation = collectibleTestAnimationPtr(name + ".tgs") + value.OfficialDocumentID = 5200000000000000000 + id%1000 + return value + } + bundle := domain.StarGiftCatalogBundleWrite{ + Catalog: domain.StarGiftCatalogWrite{ + Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, + Document: collectibleTestDocument(baseID, "official.tgs"), Blob: collectibleTestBlob(baseID, "official"), + Animation: collectibleTestAnimation("official.tgs"), Actor: "integration", CommandID: "official-catalog-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA, + OfficialSourceJSON: []byte(`{"id":5170145012310081615,"sold_out":true,"birthday":false}`), + }, + Collectible: &domain.StarGiftCollectibleWrite{ + UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "official-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleModel, baseID+1, "model")}, + Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern")}, + Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")}, + Actor: "integration", CommandID: "official-pool-" + suffix, + OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA, + }, + } + result, err := store.CreateCatalogBundle(ctx, bundle) + if err != nil { + t.Fatalf("create official bundle: %v", err) + } + if result.Catalog.Gift.ID == 0 || result.Collectible == nil || result.Catalog.Gift.UpgradeStars != 100 { + t.Fatalf("bundle result = %+v", result) + } + var sourceID int64 + var soldOut bool + if err := pool.QueryRow(ctx, ` +SELECT official_gift_id, (official_source->>'sold_out')::boolean +FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).Scan(&sourceID, &soldOut); err != nil { + t.Fatal(err) + } + if sourceID != 5170145012310081615 || !soldOut { + t.Fatalf("source id=%d sold_out=%v", sourceID, soldOut) + } + + failing := bundle + failing.Catalog.CommandID = "official-rollback-" + suffix + failing.Catalog.Document = collectibleTestDocument(baseID+100, "rollback.tgs") + failing.Catalog.Blob = collectibleTestBlob(baseID+100, "rollback") + failing.Collectible = &domain.StarGiftCollectibleWrite{ + UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "rollback-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{ + attribute(domain.StarGiftCollectibleModel, baseID+101, "duplicate"), + attribute(domain.StarGiftCollectibleModel, baseID+102, "duplicate"), + }, + Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern")}, + Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")}, + Actor: "integration", CommandID: "rollback-pool-" + suffix, + } + if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("failing bundle err=%v", err) + } + var rows int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM star_gift_catalog_revisions WHERE command_id=$1`, failing.Catalog.CommandID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("failed bundle left %d catalog revisions", rows) + } +} diff --git a/internal/store/postgres/star_gift_purchase.go b/internal/store/postgres/star_gift_purchase.go new file mode 100644 index 00000000..7e2efca4 --- /dev/null +++ b/internal/store/postgres/star_gift_purchase.go @@ -0,0 +1,344 @@ +package postgres + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *StarGiftLifecycleStore) IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) { + if s == nil || s.db == nil || form.FormID != 0 || form.BuyerUserID <= 0 || !validLifecyclePeer(form.To) || + form.GiftID <= 0 || form.RevisionID <= 0 || form.ChargeStars <= 0 || form.IssuedAt <= 0 || + form.ExpiresAt != form.IssuedAt+600 || len([]rune(form.Message)) > 128 { + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid + } + for attempt := 0; attempt < 8; attempt++ { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return domain.StarGiftPurchaseForm{}, fmt.Errorf("generate star gift form id: %w", err) + } + form.FormID = int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff) + if form.FormID == 0 { + form.FormID = 1 + } + _, err := s.db.Exec(ctx, `INSERT INTO star_gift_purchase_forms(buyer_user_id,form_id,gift_id,revision_id, +recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,charge_stars,issued_at,expires_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, form.BuyerUserID, form.FormID, form.GiftID, form.RevisionID, + string(form.To.Type), form.To.ID, form.IncludeUpgrade, form.HideName, form.Message, form.ChargeStars, form.IssuedAt, form.ExpiresAt) + if err == nil { + return form, nil + } + if !isUniqueViolation(err) { + return domain.StarGiftPurchaseForm{}, err + } + } + return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable +} + +func (s *StarGiftLifecycleStore) ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error { + if s == nil || s.db == nil { + return domain.ErrStarGiftUnavailable + } + return validateStarGiftPurchaseForm(ctx, s.db, req, false) +} + +func validateStarGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarGiftPurchaseRequest, lock bool) error { + if req.BuyerUserID <= 0 || req.FormID == 0 || req.Date <= 0 { + return domain.ErrStarGiftFormExpired + } + query := `SELECT gift_id,revision_id,recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message, +charge_stars,issued_at,expires_at FROM star_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2` + if lock { + query += ` FOR UPDATE` + } + var form domain.StarGiftPurchaseForm + var peerType string + err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).Scan(&form.GiftID, &form.RevisionID, &peerType, &form.To.ID, + &form.IncludeUpgrade, &form.HideName, &form.Message, &form.ChargeStars, &form.IssuedAt, &form.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftFormExpired + } + if err != nil { + return err + } + form.FormID, form.BuyerUserID, form.To.Type = req.FormID, req.BuyerUserID, domain.PeerType(peerType) + if form.ExpiresAt < req.Date { + return domain.ErrStarGiftFormExpired + } + if form.To != req.To || form.GiftID != req.GiftID || form.IncludeUpgrade != req.IncludeUpgrade || + form.HideName != req.HideName || form.Message != req.Message { + return domain.ErrStarGiftFormPurposeInvalid + } + if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars { + return domain.ErrStarGiftFormAmountMismatch + } + return nil +} + +func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + req.CommandKey = strings.TrimSpace(req.CommandKey) + if s == nil || s.db == nil || req.BuyerUserID <= 0 || !validLifecyclePeer(req.To) || req.GiftID <= 0 || + req.FormID == 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftInvalid + } + if replay, found, err := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found { + return replay, err + } + if err := s.ValidateStarGiftPurchaseForm(ctx, req); err != nil { + return domain.StarGiftPurchaseResult{}, err + } + if req.To.Type == domain.PeerTypeChannel { + return s.purchaseStarGiftToChannel(ctx, req) + } + if s.messages == nil { + return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable + } + fingerprint := starGiftPurchaseFingerprint(req) + messageReq := domain.SendPrivateTextRequest{SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID, + RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:], + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true}}}} + var result domain.StarGiftPurchaseResult + hooks := privateSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { + if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req) + if err != nil { + return err + } + sticker := gift.Sticker + send.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID, + Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, Sticker: &sticker, Message: req.Message, + FromUserID: req.BuyerUserID, PeerUserID: req.To.ID, To: req.To, NameHidden: req.HideName, Saved: true, + CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, + PrepaidUpgradeHash: saved.PrepaidUpgradeHash, UpgradePriceStars: gift.UpgradeStars, + UpgradeStars: saved.PrepaidUpgradeStars}}} + result.Gift, result.Saved, result.Balance = gift, saved, balance + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + msgID := sent.RecipientMessage.ID + if msgID <= 0 { + msgID = sent.SenderMessage.ID + } + result.Saved.MsgID = msgID + id, err := NewStarGiftStore(tx).Create(ctx, result.Saved) + if err != nil { + return err + } + result.Saved.ID = id + return s.insertStarGiftPurchaseCommand(ctx, tx, req, result.Saved.ID, result.Gift.Stars+result.Saved.PrepaidUpgradeStars, result.Balance.Balance) + }, + } + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPurchaseResult{}, err + } + result.Send, result.Duplicate = sent, sent.Duplicate + if sent.Duplicate { + replay, _, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent) + return replay, replayErr + } + return result, nil +} + +func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) { + var result domain.StarGiftPurchaseResult + err := withTx(ctx, s.db, "purchase star gift for channel", func(tx pgx.Tx) error { + if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req) + if err != nil { + return err + } + id, err := NewStarGiftStore(tx).Create(ctx, saved) + if err != nil { + return err + } + saved.ID, saved.SavedID = id, id + sticker := gift.Sticker + action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, + Sticker: &sticker, Message: saved.Message, FromUserID: req.BuyerUserID, PeerChannelID: req.To.ID, + SavedID: id, NameHidden: saved.NameHidden, Saved: true, CanUpgrade: gift.UpgradeStars > 0, + PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, PrepaidUpgradeHash: saved.PrepaidUpgradeHash, + UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars, + }} + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil { + return err + } + if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil { + return err + } + result = domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: balance} + return nil + }) + if err != nil { + if isUniqueViolation(err) { + if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarGiftPurchaseResult{}, err + } + return result, nil +} + +func (s *StarGiftLifecycleStore) prepareStarGiftPurchase(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest) (domain.StarGift, domain.SavedStarGift, domain.StarsBalance, error) { + var revisionID int64 + var enabled bool + var remains int + if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled,availability_remains FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID). + Scan(&revisionID, &enabled, &remains); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid + } + gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID) + if err != nil || !found || !enabled || gift.ID != req.GiftID || gift.SoldOut || gift.Auction || gift.LockedUntilDate > req.Date || + gift.Limited && remains <= 0 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid + } + if gift.RevisionID != req.RevisionID { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch + } + if gift.RequirePremium && !req.BuyerPremium { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrPremiumRequired + } + gift.AvailabilityRemains = remains + upgradePrice := int64(0) + prepayHash := "" + if gift.UpgradeStars > 0 || req.IncludeUpgrade { + revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID) + if err != nil || revision.Issued >= revision.SupplyTotal { + if req.IncludeUpgrade { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable + } + } else if req.IncludeUpgrade { + upgradePrice = revision.UpgradeStars + } else { + var token [32]byte + if _, err := rand.Read(token[:]); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + prepayHash = base64.RawURLEncoding.EncodeToString(token[:]) + } + } + if req.IncludeUpgrade && upgradePrice <= 0 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable + } + if gift.Stars+upgradePrice != req.ChargeStars { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch + } + var purchased int + if err := tx.QueryRow(ctx, `INSERT INTO star_gift_user_purchases(user_id,gift_id,purchased_count) VALUES($1,$2,1) +ON CONFLICT(user_id,gift_id) DO UPDATE SET purchased_count=star_gift_user_purchases.purchased_count+1,updated_at=now() +WHERE NOT $3 OR star_gift_user_purchases.purchased_count<$4 RETURNING purchased_count`, req.BuyerUserID, gift.ID, + gift.LimitedPerUser, gift.PerUserTotal).Scan(&purchased); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable + } + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + if gift.Limited { + if tag, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=availability_remains-1, +first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,last_sale_date=$2,updated_at=now() +WHERE gift_id=$1 AND availability_remains>0`, gift.ID, req.Date); err != nil || tag.RowsAffected() != 1 { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable + } + } else if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END, +last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + charge := gift.Stars + upgradePrice + balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID, + domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: charge}, domain.StarsReasonGift, + req.To, req.Date, "Star gift") + if err != nil { + return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err + } + saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID, + Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice, + PrepaidUpgradeHash: prepayHash, Message: req.Message} + return gift, saved, balance, nil +} + +func (s *StarGiftLifecycleStore) insertStarGiftPurchaseCommand(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest, savedID, charge, balance int64) error { + _, err := tx.Exec(ctx, `INSERT INTO star_gift_purchase_commands(buyer_user_id,command_key,gift_id,recipient_peer_type, +recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.CommandKey, req.GiftID, string(req.To.Type), req.To.ID, + savedID, req.FormID, charge, balance, req.Date) + return err +} + +func (s *StarGiftLifecycleStore) loadStarGiftPurchaseReplay(ctx context.Context, req domain.StarGiftPurchaseRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPurchaseResult, bool, error) { + var giftID, recipientID, savedID, formID, charge, balance int64 + var recipientType string + err := s.db.QueryRow(ctx, `SELECT gift_id,recipient_peer_type,recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after +FROM star_gift_purchase_commands WHERE buyer_user_id=$1 AND command_key=$2`, req.BuyerUserID, req.CommandKey). + Scan(&giftID, &recipientType, &recipientID, &savedID, &formID, &charge, &balance) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftPurchaseResult{}, false, nil + } + if err != nil { + return domain.StarGiftPurchaseResult{}, false, err + } + if giftID != req.GiftID || recipientType != string(req.To.Type) || recipientID != req.To.ID || formID != req.FormID || charge <= 0 { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + saved, found, err := savedStarGiftByID(ctx, s.db, savedID) + if err != nil || !found { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + if saved.Owner != req.To || saved.GiftID != req.GiftID || saved.NameHidden != req.HideName || saved.Message != req.Message || + (saved.PrepaidUpgradeStars > 0) != req.IncludeUpgrade { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + gift, found, err := NewStarGiftStore(s.db).CatalogRevision(ctx, saved.RevisionID) + if err != nil || !found { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + if req.To.Type == domain.PeerTypeUser && sent.SenderMessage.ID == 0 { + if s.messages == nil { + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftUnavailable + } + fingerprint := starGiftPurchaseFingerprint(req) + replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID, + RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), IdempotencyFingerprint: fingerprint[:], + }) + if replayErr != nil || !replayFound { + if replayErr != nil { + return domain.StarGiftPurchaseResult{}, false, replayErr + } + return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid + } + sent = replay + } + return domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance}, + Send: sent, Duplicate: true}, true, nil +} + +func starGiftPurchaseFingerprint(req domain.StarGiftPurchaseRequest) [32]byte { + return sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-purchase:v1:%d:%s:%d:%d:%t:%t:%s", + req.BuyerUserID, req.To.Type, req.To.ID, req.GiftID, req.IncludeUpgrade, req.HideName, req.Message))) +} diff --git a/internal/store/postgres/star_gift_upgrade.go b/internal/store/postgres/star_gift_upgrade.go index 3bf3dd14..d7cfe383 100644 --- a/internal/store/postgres/star_gift_upgrade.go +++ b/internal/store/postgres/star_gift_upgrade.go @@ -21,18 +21,38 @@ import ( // upgrades. It intentionally shares MessageStore's allocator and transaction // machinery so Stars, issuance, the saved gift and durable updates commit once. type StarGiftUpgradeStore struct { - db sqlcgen.DBTX - messages *MessageStore + db sqlcgen.DBTX + messages *MessageStore + lifecycle domain.StarGiftLifecyclePolicy } -func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore { - return &StarGiftUpgradeStore{db: db, messages: messages} +type StarGiftUpgradeOption func(*StarGiftUpgradeStore) + +func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption { + return func(s *StarGiftUpgradeStore) { + if policy.Valid() { + s.lifecycle = policy + } + } +} + +func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore { + s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250, + }} + for _, opt := range opts { + opt(s) + } + return s } func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() || - req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) || - req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { + (req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || + (req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) || + req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) || + (!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) || + req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref) @@ -48,7 +68,11 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S "telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t", commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails, ))) - randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey) + messageSenderID := saved.FromUserID + if saved.Owner.Type == domain.PeerTypeChannel { + messageSenderID = domain.OfficialSystemUserID + } + randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey) placeholder := &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ @@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S }, } messageReq := domain.SendPrivateTextRequest{ - SenderUserID: saved.FromUserID, + SenderUserID: messageSenderID, RecipientUserID: req.UserID, RandomID: randomID, Media: placeholder, @@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S if err != nil { return err } + var craftable bool + if err := tx.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM star_gift_collectible_models +WHERE collectible_revision_id=$1 AND crafted +)`, revision.ID).Scan(&craftable); err != nil { + return fmt.Errorf("load collectible craft capability: %w", err) + } + craftChancePermille := 0 + canCraftAt := 0 + if craftable { + craftChancePermille = s.lifecycle.CraftChancePermille + canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) + } if revision.Issued >= revision.SupplyTotal { return domain.ErrStarGiftCollectibleSoldOut } @@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S INSERT INTO unique_star_gifts (id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num, owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id, - backdrop_attribute_id, keep_original_details) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id, + craft_chance_permille, offer_min_stars) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num, - string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil { + string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails, + string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil { return fmt.Errorf("insert unique star gift: %w", err) } if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil { @@ -145,14 +184,21 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, } if _, err := tx.Exec(ctx, ` UPDATE peer_star_gifts -SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0 -WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil { +SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0, + transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6, + drop_original_details_stars=$7,can_craft_at=$8 +WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID, + s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds), + starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds), + s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil { return fmt.Errorf("upgrade saved star gift: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_upgrade_commands - (user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after) -VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil { + (user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after, + charge_stars, require_prepaid, keep_original_details) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance, + req.ChargeStars, req.RequirePrepaid, req.KeepOriginalDetails); err != nil { return fmt.Errorf("insert star gift upgrade command: %w", err) } @@ -166,21 +212,20 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq locked.UniqueGiftID = uniqueID locked.PrepaidUpgradeStars = 0 locked.ConvertStars = 0 + locked.TransferStars = s.lifecycle.TransferStars + locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds) + locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds) + locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds) + locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars + locked.CanCraftAt = canCraftAt locked.Unique = &unique result.Saved, result.Unique, result.Balance = locked, unique, balance + action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID) messageReq.Media = &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ - Kind: domain.MessageServiceActionStarGiftUnique, - StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: unique, FromUserID: func() int64 { - if locked.NameHidden { - return 0 - } - return locked.FromUserID - }(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved, - PrepaidUpgrade: req.RequirePrepaid, - }, + Kind: domain.MessageServiceActionStarGiftUnique, + StarGiftUnique: action, }, } return nil @@ -201,6 +246,40 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq return fmt.Errorf("save star gift upgrade message id lost aggregate row") } result.Saved.UpgradeMsgID = ownerMessageID + if result.Saved.Owner.Type == domain.PeerTypeUser { + edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent) + if err != nil { + return err + } + result.SourceEdits = edits + ownerEditPts := 0 + for _, edit := range edits { + if edit.UserID == req.UserID { + ownerEditPts = edit.Event.Pts + break + } + } + if ownerEditPts <= 0 { + return fmt.Errorf("upgrade source edit missing owner event") + } + tag, err := tx.Exec(ctx, ` +UPDATE star_gift_upgrade_commands SET source_edit_pts=$3 +WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts) + if err != nil { + return fmt.Errorf("save star gift source edit pts: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("save star gift source edit pts lost command row") + } + } else { + action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID) + if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID, + req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action, + }); err != nil { + return fmt.Errorf("append channel star gift upgrade admin log: %w", err) + } + } return nil }, } @@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq return result, nil } +func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction { + fromUserID := saved.FromUserID + if saved.NameHidden { + fromUserID = 0 + } + if saved.Owner.Type == domain.PeerTypeChannel { + // TDesktop recognizes a channel-owned upgrade from the official service + // peer plus action.peer=channel and action.saved_id. + fromUserID = messageSenderID + } + savedID := saved.SavedID + if saved.Owner.Type == domain.PeerTypeUser { + // For user-owned gifts messageActionStarGiftUnique.saved_id is the + // stable source gift message id. TDesktop uses this back-reference as + // inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs. + savedID = int64(saved.MsgID) + } + return &domain.MessageStarGiftUniqueAction{ + Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID, + Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid, + CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, + CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt, + } +} + +// markPrivateStarGiftSourceUpgradedTx rewrites both visible copies of the +// original gift service message in the same transaction that creates the +// unique gift message. upgrade_msg_id is box-local, so each owner projection +// must point at that owner's copy of the new service message. Every rewrite is +// a durable edit_message event with its own pts and outbox row. +func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx( + ctx context.Context, + tx pgx.Tx, + req domain.StarGiftUpgradeRequest, + saved domain.SavedStarGift, + sent domain.SendPrivateTextResult, +) ([]domain.EditedMessageForUser, error) { + if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + q := sqlcgen.New(tx) + target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.UserID, + BoxID: int32(saved.MsgID), + PeerType: string(domain.PeerTypeUser), + PeerID: saved.FromUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCollectibleInvalid + } + return nil, fmt.Errorf("lock star gift source message: %w", err) + } + boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID), + MessageSenderID: target.MessageSenderID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("list star gift source message boxes: %w", err) + } + if len(boxes) == 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + upgradeMessageIDs := make(map[int64]int, 2) + if sent.SenderMessage.OwnerUserID > 0 && sent.SenderMessage.ID > 0 { + upgradeMessageIDs[sent.SenderMessage.OwnerUserID] = sent.SenderMessage.ID + } + if sent.RecipientMessage.OwnerUserID > 0 && sent.RecipientMessage.ID > 0 { + upgradeMessageIDs[sent.RecipientMessage.OwnerUserID] = sent.RecipientMessage.ID + } + edits := make([]domain.EditedMessageForUser, 0, len(boxes)) + var privateMediaJSON []byte + for _, box := range boxes { + upgradeMessageID := upgradeMessageIDs[box.OwnerUserID] + if upgradeMessageID <= 0 { + return nil, fmt.Errorf("upgrade service message missing box for user %d", box.OwnerUserID) + } + media, err := decodeMessageMedia(box.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode star gift source media: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGift || media.ServiceAction.StarGift == nil { + return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID) + } + action := media.ServiceAction.StarGift + if action.UpgradeMsgID != 0 && action.UpgradeMsgID != upgradeMessageID { + return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID) + } + action.UpgradeMsgID = upgradeMessageID + action.CanUpgrade = false + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return nil, fmt.Errorf("encode upgraded star gift source media: %w", err) + } + pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return nil, fmt.Errorf("allocate star gift source edit pts: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media=$3, pts=$4 +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts)) + if err != nil { + return nil, fmt.Errorf("update star gift source message box: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, fmt.Errorf("update star gift source message box lost row") + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return nil, err + } + msg.Media = media + msg.Pts = pts + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return nil, err + } + event := domain.UpdateEvent{ + UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage, + Pts: pts, PtsCount: 1, Date: req.Date, Message: msg, + } + if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil { + return nil, fmt.Errorf("append star gift source edit event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.UserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID, + }); err != nil { + return nil, fmt.Errorf("enqueue star gift source edit: %w", err) + } + if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { + privateMediaJSON = mediaJSON + } + edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if len(privateMediaJSON) == 0 { + return nil, fmt.Errorf("upgrade source message missing private media projection") + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media=$3 +WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil { + return nil, fmt.Errorf("update star gift source private message: %w", err) + } + return edits, nil +} + +func starGiftReadyAt(date, delaySeconds int) int { + if date <= 0 || delaySeconds <= 0 { + return 0 + } + const maxProtocolDate = int(1<<31 - 1) + if delaySeconds > maxProtocolDate-date { + return maxProtocolDate + } + return date + delaySeconds +} + func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { where, args := savedStarGiftRefWhere(ref) + return lockSavedStarGiftWhere(ctx, tx, where, args...) +} + +func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) { + return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID) +} + +func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (domain.SavedStarGift, error) { row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, - p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, + p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, + p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, + p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i @@ -283,7 +537,13 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, } func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) { - rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID) + extra := "" + if table == "star_gift_collectible_models" { + extra = " AND NOT crafted" + } + rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s +WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s +ORDER BY sort_order, id`, table, extra), revisionID) if err != nil { return 0, fmt.Errorf("list collectible attributes for issuance: %w", err) } @@ -305,7 +565,7 @@ func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, re if err := rows.Err(); err != nil { return 0, err } - if len(items) == 0 || total != 1000 { + if len(items) == 0 || total <= 0 { return 0, domain.ErrStarGiftCollectibleInvalid } draw, err := rand.Int(rand.Reader, big.NewInt(int64(total))) @@ -346,20 +606,94 @@ func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain } return domain.StarGiftUpgradeResult{}, err } - var commandUniqueID int64 - var balanceAfter int64 - if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil { + receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey) + if err != nil { return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err) } - if commandUniqueID != unique.ID || saved.ID != original.ID { + if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID || + receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid || + receipt.KeepOriginalDetails != req.KeepOriginalDetails { return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid } uniqueCopy := unique saved.Unique = &uniqueCopy + sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts) + if err != nil { + return domain.StarGiftUpgradeResult{}, err + } return domain.StarGiftUpgradeResult{ - Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter}, - Send: sent, Duplicate: true, + Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter}, + Send: sent, SourceEdits: sourceEdits, Duplicate: true, }, nil } +func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) { + if saved.Owner.Type != domain.PeerTypeUser { + return nil, nil + } + if pts <= 0 || saved.MsgID <= 0 { + return nil, domain.ErrStarGiftCollectibleInvalid + } + var privateMessageID, messageSenderID int64 + err := s.db.QueryRow(ctx, ` +SELECT private_message_id,message_sender_id FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`, + req.UserID, saved.MsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID) + if errors.Is(err, pgx.ErrNoRows) { + // A later delete event is authoritative; replaying the old edit here + // would transiently resurrect the source message. + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("load star gift source replay message: %w", err) + } + boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("load star gift source replay box: %w", err) + } + if len(boxes) != 1 || int(boxes[0].BoxID) != saved.MsgID { + return nil, domain.ErrStarGiftCollectibleInvalid + } + var eventDate int + err = s.db.QueryRow(ctx, ` +SELECT date FROM user_update_events +WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, + req.UserID, pts, saved.MsgID).Scan(&eventDate) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrStarGiftCollectibleInvalid + } + return nil, fmt.Errorf("load star gift source replay event: %w", err) + } + msg, err := messageFromVisibleBoxRow(boxes[0]) + if err != nil { + return nil, err + } + msg.Pts = pts + event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: pts, PtsCount: 1, Date: eventDate, Message: msg} + return []domain.EditedMessageForUser{{UserID: req.UserID, Message: msg, Event: event}}, nil +} + +func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) { + commandKey = strings.TrimSpace(commandKey) + if s == nil || s.db == nil || userID <= 0 || commandKey == "" || len(commandKey) > 256 { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + receipt := domain.StarGiftUpgradeReceipt{UserID: userID} + err := s.db.QueryRow(ctx, ` +SELECT source_saved_gift_id,form_id,unique_gift_id,charge_stars,balance_after,source_edit_pts,require_prepaid,keep_original_details +FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, userID, commandKey).Scan( + &receipt.SourceSavedGiftID, &receipt.FormID, &receipt.UniqueGiftID, &receipt.ChargeStars, + &receipt.BalanceAfter, &receipt.SourceEditPts, &receipt.RequirePrepaid, &receipt.KeepOriginalDetails) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarGiftUpgradeReceipt{}, false, nil + } + if err != nil { + return domain.StarGiftUpgradeReceipt{}, false, err + } + return receipt, true, nil +} + var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil) diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index f937528e..364a831b 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer if err != nil { return err } + emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus) + if err != nil { + return err + } if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: userID, - Pts: int32(event.Pts), - PtsCount: int32(event.PtsCount), - Date: int32(event.Date), - EventType: string(event.Type), - EventBool: event.Bool, - EventPhone: event.Phone, - EventPeers: peers, - PeerSettings: settings, - MessageIds: messageIDs, - DialogFilter: dialogFilter, - FilterOrder: filterOrder, - FolderPeers: folderPeers, - StoryPayload: storyPayload, - ReactionPayload: reactionPayload, - MaxID: pgInt32NonNegative(event.MaxID), - StillUnreadCount: int32(event.StillUnreadCount), - ChannelPts: int32(event.ChannelPts), - FilterID: pgInt32NonNegative(event.FilterID), - TagsEnabled: event.TagsEnabled, - FolderID: pgInt32NonNegative(event.FolderID), - MessageBoxID: messageID, - PeerType: peerType, - PeerID: peerID, + UserID: userID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(event.Type), + EventBool: event.Bool, + EventPhone: event.Phone, + EventPeers: peers, + PeerSettings: settings, + MessageIds: messageIDs, + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + StoryPayload: storyPayload, + ReactionPayload: reactionPayload, + EmojiStatusPayload: emojiStatusPayload, + MaxID: pgInt32NonNegative(event.MaxID), + StillUnreadCount: int32(event.StillUnreadCount), + ChannelPts: int32(event.ChannelPts), + FilterID: pgInt32NonNegative(event.FilterID), + TagsEnabled: event.TagsEnabled, + FolderID: pgInt32NonNegative(event.FolderID), + MessageBoxID: messageID, + PeerType: peerType, + PeerID: peerID, }); err != nil { return err } @@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim if err != nil { return nil, fmt.Errorf("decode reaction payload: %w", err) } + emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode emoji status payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) @@ -420,6 +429,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim TagsEnabled: row.TagsEnabled, FolderID: int(row.FolderID), Reaction: reaction, + EmojiStatus: emojiStatus, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev if err != nil { return nil, fmt.Errorf("decode reaction payload: %w", err) } + emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode emoji status payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) @@ -614,6 +628,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev TagsEnabled: row.TagsEnabled, FolderID: int(row.FolderID), Reaction: reaction, + EmojiStatus: emojiStatus, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) { return decodeStoryReaction(raw) } +func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) { + if !status.Valid() { + return nil, domain.ErrStarGiftCollectibleInvalid + } + raw, err := json.Marshal(status) + if err != nil { + return nil, fmt.Errorf("marshal event emoji status: %w", err) + } + return raw, nil +} + +func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) { + if raw == "" || raw == "{}" || raw == "null" { + return domain.UserEmojiStatus{}, nil + } + var status domain.UserEmojiStatus + if err := json.Unmarshal([]byte(raw), &status); err != nil { + return domain.UserEmojiStatus{}, err + } + if !status.Valid() { + return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid + } + return status, nil +} + type peerSettingsJSON struct { AddContact bool `json:"add_contact,omitempty"` BlockContact bool `json:"block_contact,omitempty"` diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index 9f87a507..f865e398 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -152,27 +153,30 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon Results: make([]domain.User, 0, len(rows)), } for _, row := range rows { + collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible) u := domain.User{ - ID: row.ID, - AccessHash: row.AccessHash, - Phone: row.Phone, - FirstName: row.FirstName, - LastName: row.LastName, - About: row.About, - Username: row.Username, - CountryCode: row.CountryCode, - Verified: row.Verified, - Support: row.Support, - Bot: row.IsBot, - BotInfoVersion: int(row.BotInfoVersion), - PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), - EmojiStatusDocumentID: row.EmojiStatusDocumentID, - EmojiStatusUntil: int(row.EmojiStatusUntil), - Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), - ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), - LastSeenAt: int(row.LastSeenAt), - Contact: row.Contact, - Mutual: row.Mutual, + ID: row.ID, + AccessHash: row.AccessHash, + Phone: row.Phone, + FirstName: row.FirstName, + LastName: row.LastName, + About: row.About, + Username: row.Username, + CountryCode: row.CountryCode, + Verified: row.Verified, + Support: row.Support, + Bot: row.IsBot, + BotInfoVersion: int(row.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), + EmojiStatusDocumentID: row.EmojiStatusDocumentID, + EmojiStatusUntil: int(row.EmojiStatusUntil), + EmojiStatusCollectible: collectible, + Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), + LinkedCommunityID: row.LinkedCommunityID, + LastSeenAt: int(row.LastSeenAt), + Contact: row.Contact, + Mutual: row.Mutual, } if row.Contact { out.MyResults = append(out.MyResults, u) @@ -218,7 +222,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s }() qtx := s.q.WithTx(tx) var lockedUserID int64 - if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { + if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUsernameNotOccupied } @@ -355,22 +359,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in return out, nil } -// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 -func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { - row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{ - ID: userID, - EmojiStatusDocumentID: documentID, - EmojiStatusUntil: int64(until), - }) +// UpdateEmojiStatus atomically replaces the complete emoji-status snapshot. +func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) + if err != nil { + return domain.User{}, err + } + params := sqlcgen.UpdateUserEmojiStatusParams{ + ID: userID, + EmojiStatusDocumentID: status.DocumentID, + EmojiStatusUntil: int64(status.Until), + EmojiStatusCollectibleID: collectibleID, + EmojiStatusCollectible: collectibleJSON, + } + var row sqlcgen.User + if status.Collectible.Empty() { + row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params) + } else { + // Serialize selection against transfer/export/burn. RPC-level ownership + // checks are advisory; this lock is the write-boundary invariant that + // prevents a concurrent lifecycle commit from leaving a non-owned gift + // installed after its invalidation trigger already ran. + err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error { + row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) + return err + }) + } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return domain.User{}, err + } return domain.User{}, fmt.Errorf("update user emoji status: %w", err) } return userFromModel(row), nil } +// UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event +// and dispatch outbox row as one aggregate transaction. This is the production +// boundary used by account.updateEmojiStatus; no success can expose a users +// row whose change is absent from updates.getDifference. +func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) { + collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, err + } + if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status || + event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid + } + params := sqlcgen.UpdateUserEmojiStatusParams{ + ID: userID, + EmojiStatusDocumentID: status.DocumentID, + EmojiStatusUntil: int64(status.Until), + EmojiStatusCollectibleID: collectibleID, + EmojiStatusCollectible: collectibleJSON, + } + var row sqlcgen.User + err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error { + row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) + if err != nil { + return err + } + event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch( + ctx, userID, event, excludeAuthKeyID, excludeSessionID, + ) + return err + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound + } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return domain.User{}, domain.UpdateEvent{}, err + } + return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err) + } + return userFromModel(row), event, nil +} + +func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) { + if !status.Collectible.Empty() { + var lockedID int64 + if err := db.QueryRow(ctx, ` +SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, status.Collectible.CollectibleID).Scan(&lockedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid + } + return sqlcgen.User{}, err + } + gift, found, err := NewStarGiftStore(db).UniqueByID(ctx, lockedID) + if err != nil { + return sqlcgen.User{}, err + } + expected, valid := domain.CollectibleEmojiStatus(gift) + if !found || !valid || gift.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) || + gift.Burned || gift.OwnerAddress != "" || expected != status.Collectible { + return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid + } + } + return q.UpdateUserEmojiStatus(ctx, params) +} + // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{ @@ -463,29 +555,74 @@ func escapeLike(s string) string { } func userFromModel(r sqlcgen.User) domain.User { - return domain.User{ - ID: r.ID, - AccessHash: r.AccessHash, - Phone: r.Phone, - SignupEmail: r.SignupEmail, - FirstName: r.FirstName, - LastName: r.LastName, - About: r.About, - Username: r.Username, - CountryCode: r.CountryCode, - Verified: r.Verified, - Support: r.Support, - Bot: r.IsBot, - BotInfoVersion: int(r.BotInfoVersion), - PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), - EmojiStatusDocumentID: r.EmojiStatusDocumentID, - EmojiStatusUntil: int(r.EmojiStatusUntil), - Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, - PersonalChannelID: r.PersonalChannelID, - Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), - ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), - LastSeenAt: int(r.LastSeenAt), + collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible) + u := domain.User{ + ID: r.ID, + AccessHash: r.AccessHash, + Phone: r.Phone, + SignupEmail: r.SignupEmail, + FirstName: r.FirstName, + LastName: r.LastName, + About: r.About, + Username: r.Username, + CountryCode: r.CountryCode, + Verified: r.Verified, + Support: r.Support, + Bot: r.IsBot, + BotInfoVersion: int(r.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), + EmojiStatusDocumentID: r.EmojiStatusDocumentID, + EmojiStatusUntil: int(r.EmojiStatusUntil), + EmojiStatusCollectible: collectible, + Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, + PersonalChannelID: r.PersonalChannelID, + LinkedCommunityID: r.LinkedCommunityID, + Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), + LastSeenAt: int(r.LastSeenAt), + Deleted: r.DeletedAt.Valid, + DeletionSource: domain.AccountDeletionSource(r.DeletionSource), + DeletionReason: r.DeletionReason, + CreatedAt: r.CreatedAt.Time, + AccountDeleteAt: r.AccountDeleteAt.Time, } + if r.DeletedAt.Valid { + u.DeletedAt = r.DeletedAt.Time.Unix() + return u.DeletedTombstone() + } + return u +} + +func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) { + if !status.Valid() { + return nil, nil, domain.ErrStarGiftCollectibleInvalid + } + if status.Collectible.Empty() { + return []byte(`{}`), nil, nil + } + raw, err := json.Marshal(status.Collectible) + if err != nil { + return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err) + } + id := status.Collectible.CollectibleID + return raw, &id, nil +} + +func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible { + var collectible domain.EmojiStatusCollectible + if err := json.Unmarshal(raw, &collectible); err != nil { + panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err)) + } + if id == nil { + if !collectible.Empty() { + panic("users emoji-status invariant: snapshot exists without collectible id") + } + return domain.EmojiStatusCollectible{} + } + if !collectible.Valid() || collectible.CollectibleID != *id { + panic("users emoji-status invariant: incomplete or mismatched collectible snapshot") + } + return collectible } func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor { diff --git a/internal/store/private_send_idempotency.go b/internal/store/private_send_idempotency.go index f737cc13..80346527 100644 --- a/internal/store/private_send_idempotency.go +++ b/internal/store/private_send_idempotency.go @@ -69,11 +69,17 @@ type channelSendFingerprintPayload struct { } type monoforumSendFingerprintPayload struct { - Version int `json:"version"` - ChannelID int64 `json:"channel_id"` - SavedPeer domain.Peer `json:"saved_peer"` - Message string `json:"message"` - Entities []domain.MessageEntity `json:"entities"` + Version int `json:"version"` + ChannelID int64 `json:"channel_id"` + SavedPeer domain.Peer `json:"saved_peer"` + Message string `json:"message"` + Entities []domain.MessageEntity `json:"entities"` + Media *domain.MessageMedia `json:"media"` + ReplyTo *domain.MessageReply `json:"reply_to"` + Silent bool `json:"silent"` + NoForwards bool `json:"noforwards"` + SuggestedPost *domain.SuggestedPost `json:"suggested_post,omitempty"` + AllowPaidStars int64 `json:"allow_paid_stars"` } // PrivateSendFingerprint returns a SHA-256 fingerprint of the original send @@ -155,11 +161,17 @@ func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, e return append([]byte(nil), req.IdempotencyFingerprint...), nil } payload, err := json.Marshal(monoforumSendFingerprintPayload{ - Version: channelSendFingerprintVersion, - ChannelID: req.MonoforumID, - SavedPeer: req.SavedPeer, - Message: req.Message, - Entities: req.Entities, + Version: channelSendFingerprintVersion, + ChannelID: req.MonoforumID, + SavedPeer: req.SavedPeer, + Message: req.Message, + Entities: req.Entities, + Media: req.Media, + ReplyTo: req.ReplyTo, + Silent: req.Silent, + NoForwards: req.NoForwards, + SuggestedPost: req.SuggestedPost, + AllowPaidStars: req.AllowPaidStars, }) if err != nil { return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err) diff --git a/internal/store/redisstore/bot_callback.go b/internal/store/redisstore/bot_callback.go new file mode 100644 index 00000000..a3b51e58 --- /dev/null +++ b/internal/store/redisstore/bot_callback.go @@ -0,0 +1,150 @@ +package redisstore + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const botCallbackAnswerChannel = "telesrv:bot_callback:answers" + +type BotCallbackRegistryStore struct { + c redis.UniversalClient +} + +func NewBotCallbackRegistryStore(c redis.UniversalClient) *BotCallbackRegistryStore { + return &BotCallbackRegistryStore{c: c} +} + +func botCallbackKey(queryID int64) string { + return fmt.Sprintf("telesrv:bot_callback:%d", queryID) +} + +var putBotCallbackScript = redis.NewScript(` +if redis.call('EXISTS', KEYS[1]) ~= 0 then + return 0 +end +redis.call('HSET', KEYS[1], + 'bot_user_id', ARGV[1], + 'user_id', ARGV[2], + 'created_at_unix_nano', ARGV[3]) +redis.call('PEXPIRE', KEYS[1], ARGV[4]) +return 1 +`) + +func (s *BotCallbackRegistryStore) PutBotCallbackPending(ctx context.Context, pending store.BotCallbackPending, ttl time.Duration) (bool, error) { + if s == nil || s.c == nil || pending.QueryID == 0 || pending.BotUserID <= 0 || pending.UserID <= 0 || ttl <= 0 { + return false, fmt.Errorf("invalid bot callback pending") + } + createdAt := pending.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + result, err := putBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(pending.QueryID)}, + pending.BotUserID, pending.UserID, createdAt.UnixNano(), ttl.Milliseconds()).Int64() + if err != nil { + return false, fmt.Errorf("put bot callback pending: %w", err) + } + return result == 1, nil +} + +var resolveBotCallbackScript = redis.NewScript(` +if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then + return 0 +end +if redis.call('HEXISTS', KEYS[1], 'answer') ~= 0 then + return 0 +end +redis.call('HSET', KEYS[1], 'answer', ARGV[2]) +redis.call('PUBLISH', ARGV[3], ARGV[4]) +return 1 +`) + +func (s *BotCallbackRegistryStore) ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return false, nil + } + answerJSON, err := json.Marshal(answer) + if err != nil { + return false, fmt.Errorf("marshal bot callback answer: %w", err) + } + pushJSON, err := json.Marshal(store.BotCallbackAnswerPush{QueryID: queryID, BotUserID: botUserID, Answer: answer}) + if err != nil { + return false, fmt.Errorf("marshal bot callback answer push: %w", err) + } + result, err := resolveBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, + strconv.FormatInt(botUserID, 10), answerJSON, botCallbackAnswerChannel, pushJSON).Int64() + if err != nil { + return false, fmt.Errorf("resolve bot callback: %w", err) + } + return result == 1, nil +} + +func (s *BotCallbackRegistryStore) GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return domain.BotCallbackAnswer{}, false, nil + } + values, err := s.c.HMGet(ctx, botCallbackKey(queryID), "bot_user_id", "answer").Result() + if err != nil { + return domain.BotCallbackAnswer{}, false, fmt.Errorf("get bot callback answer: %w", err) + } + if len(values) != 2 || values[0] == nil || values[1] == nil || fmt.Sprint(values[0]) != strconv.FormatInt(botUserID, 10) { + return domain.BotCallbackAnswer{}, false, nil + } + var answer domain.BotCallbackAnswer + if err := json.Unmarshal([]byte(fmt.Sprint(values[1])), &answer); err != nil { + return domain.BotCallbackAnswer{}, false, fmt.Errorf("decode bot callback answer: %w", err) + } + return answer, true, nil +} + +var deleteBotCallbackScript = redis.NewScript(` +if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then + return 0 +end +return redis.call('DEL', KEYS[1]) +`) + +func (s *BotCallbackRegistryStore) DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return nil + } + if _, err := deleteBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, strconv.FormatInt(botUserID, 10)).Result(); err != nil && err != redis.Nil { + return fmt.Errorf("delete bot callback pending: %w", err) + } + return nil +} + +func (s *BotCallbackRegistryStore) SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, store.BotCallbackAnswerPush)) error { + if s == nil || s.c == nil || handle == nil { + return nil + } + pubsub := s.c.Subscribe(ctx, botCallbackAnswerChannel) + defer pubsub.Close() + if _, err := pubsub.Receive(ctx); err != nil { + return fmt.Errorf("subscribe bot callback answers: %w", err) + } + channel := pubsub.Channel(redis.WithChannelSize(256)) + for { + select { + case <-ctx.Done(): + return nil + case message, ok := <-channel: + if !ok { + return nil + } + var push store.BotCallbackAnswerPush + if err := json.Unmarshal([]byte(message.Payload), &push); err != nil || push.QueryID == 0 || push.BotUserID <= 0 { + continue + } + handle(ctx, push) + } + } +} diff --git a/internal/store/redisstore/bot_callback_integration_test.go b/internal/store/redisstore/bot_callback_integration_test.go new file mode 100644 index 00000000..bf89f2ab --- /dev/null +++ b/internal/store/redisstore/bot_callback_integration_test.go @@ -0,0 +1,76 @@ +package redisstore + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestRedisBotCallbackRegistryCrossInstanceCASAndPubSub(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + clientA, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatal(err) + } + defer clientA.Close() + clientB, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatal(err) + } + defer clientB.Close() + a, b := NewBotCallbackRegistryStore(clientA), NewBotCallbackRegistryStore(clientB) + queryID := time.Now().UnixNano() + defer a.DeleteBotCallbackPending(context.Background(), 1001, queryID) + pushes := make(chan store.BotCallbackAnswerPush, 1) + subscribed := make(chan struct{}) + go func() { + _ = b.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) { + select { + case pushes <- push: + default: + } + }) + }() + // Subscribe uses Redis' acknowledgement before consuming Channel. Give that + // acknowledgement one bounded scheduling turn before publishing. + time.AfterFunc(50*time.Millisecond, func() { close(subscribed) }) + <-subscribed + created, err := a.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2001}, time.Second) + if err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if duplicate, err := b.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2002}, time.Second); err != nil || duplicate { + t.Fatalf("duplicate=%v err=%v", duplicate, err) + } + answer := domain.BotCallbackAnswer{Message: "done", CacheTime: 3} + if resolved, err := b.ResolveBotCallback(ctx, 9999, queryID, answer); err != nil || resolved { + t.Fatalf("foreign resolve=%v err=%v", resolved, err) + } + if resolved, err := b.ResolveBotCallback(ctx, 1001, queryID, answer); err != nil || !resolved { + t.Fatalf("owner resolve=%v err=%v", resolved, err) + } + if second, err := a.ResolveBotCallback(ctx, 1001, queryID, domain.BotCallbackAnswer{Message: "second"}); err != nil || second { + t.Fatalf("second resolve=%v err=%v", second, err) + } + stored, found, err := a.GetBotCallbackAnswer(ctx, 1001, queryID) + if err != nil || !found || stored.Message != "done" { + t.Fatalf("stored=%#v found=%v err=%v", stored, found, err) + } + select { + case push := <-pushes: + if push.QueryID != queryID || push.BotUserID != 1001 || push.Answer.Message != "done" { + t.Fatalf("push=%#v", push) + } + case <-ctx.Done(): + t.Fatal("missing cross-instance callback pubsub") + } +} diff --git a/internal/store/redisstore/ephemeral.go b/internal/store/redisstore/ephemeral.go new file mode 100644 index 00000000..162cc918 --- /dev/null +++ b/internal/store/redisstore/ephemeral.go @@ -0,0 +1,458 @@ +package redisstore + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + maxEncodedEphemeralMessageBytes = 2 << 20 + ephemeralPushChannel = "telesrv:ephemeral:push:v1" +) + +type EphemeralMessageStore struct { + c redis.UniversalClient +} + +func NewEphemeralMessageStore(c redis.UniversalClient) *EphemeralMessageStore { + return &EphemeralMessageStore{c: c} +} + +func (s *EphemeralMessageStore) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error { + if s == nil || s.c == nil { + return errors.New("redis ephemeral push broker is not configured") + } + if !validEphemeralPush(event) { + return errors.New("invalid ephemeral push") + } + raw, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal ephemeral push: %w", err) + } + if len(raw) > maxEncodedEphemeralMessageBytes { + return errors.New("ephemeral push exceeds encoded size limit") + } + if err := s.c.Publish(ctx, ephemeralPushChannel, raw).Err(); err != nil { + return fmt.Errorf("redis publish ephemeral push: %w", err) + } + return nil +} + +func (s *EphemeralMessageStore) SubscribeEphemeralPushes(ctx context.Context, handle func(context.Context, store.EphemeralPush)) error { + if s == nil || s.c == nil { + return errors.New("redis ephemeral push broker is not configured") + } + if handle == nil { + return errors.New("ephemeral push handler is nil") + } + pubsub := s.c.Subscribe(ctx, ephemeralPushChannel) + defer func() { _ = pubsub.Close() }() + if _, err := pubsub.Receive(ctx); err != nil { + return fmt.Errorf("redis subscribe ephemeral push: %w", err) + } + messages := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case item, ok := <-messages: + if !ok { + return nil + } + if len(item.Payload) > maxEncodedEphemeralMessageBytes { + continue + } + var event store.EphemeralPush + if strictUnmarshalEphemeral([]byte(item.Payload), &event) != nil || !validEphemeralPush(event) { + continue + } + handle(ctx, event) + } + } +} + +func validEphemeralPush(event store.EphemeralPush) bool { + if event.SourceID == "" || event.TargetUserID <= 0 || event.Date <= 0 || event.Message.ID <= 0 || + event.Message.Peer.Type != domain.PeerTypeChannel || event.Message.Peer.ID <= 0 || event.Message.ValidateStored() != nil { + return false + } + if event.TargetBusinessAuthKey != ([8]byte{}) && + (event.Message.OriginDevice.UserID != event.TargetUserID || event.Message.OriginDevice.BusinessAuthKeyID != event.TargetBusinessAuthKey) { + return false + } + switch event.Kind { + case store.EphemeralPushNew, store.EphemeralPushEdit: + return !event.Message.Deleted && event.Callback == nil && event.TargetUserID == event.Message.ReceiverUserID + case store.EphemeralPushDelete: + return event.Message.Deleted && event.Callback == nil && + (event.TargetUserID == event.Message.SenderUserID || event.TargetUserID == event.Message.ReceiverUserID) + case store.EphemeralPushCallback: + return event.Callback != nil && event.Callback.BotUserID == event.TargetUserID && + event.Callback.ID != 0 && event.Callback.UserID == event.Message.ReceiverUserID && + event.Callback.ChatInstance != 0 && len(event.Callback.Data) <= domain.MaxEphemeralCallbackDataBytes && event.Callback.InlineMessage == nil && + event.Callback.MessageID == event.Message.ID && event.Callback.Peer == event.Message.Peer && + event.TargetUserID == event.Message.SenderUserID + default: + return false + } +} + +func ephemeralPeerTag(peer domain.Peer) string { + // A shared Redis Cluster hash tag keeps the message and random-id index in + // the same slot, so the two-key Lua transaction remains cluster-safe. + return fmt.Sprintf("{ephemeral:%s:%d}", peer.Type, peer.ID) +} + +func ephemeralMessageKey(peer domain.Peer, id int) string { + return fmt.Sprintf("telesrv:%s:message:%d", ephemeralPeerTag(peer), id) +} + +func ephemeralRandomKey(message domain.EphemeralMessage) string { + return fmt.Sprintf("telesrv:%s:random:%d:%d:%d", ephemeralPeerTag(message.Peer), + message.SenderUserID, message.ReceiverUserID, message.RandomID) +} + +func ephemeralCallbackActionKey(queryID int64) string { + return fmt.Sprintf("telesrv:ephemeral:callback_action:%d", queryID) +} + +func (s *EphemeralMessageStore) PutEphemeralCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) { + if s == nil || s.c == nil || action.QueryID == 0 || action.BotUserID <= 0 || action.UserID <= 0 || + action.Peer.Type != domain.PeerTypeChannel || action.Peer.ID <= 0 || action.MessageID <= 0 || + action.Device.UserID != action.UserID || action.Device.BusinessAuthKeyID == ([8]byte{}) || action.CreatedAt.IsZero() || + !action.ExpiresAt.After(action.CreatedAt) || action.ExpiresAt.Sub(action.CreatedAt) > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralInvalid + } + ttl := time.Until(action.ExpiresAt) + if ttl <= 0 || ttl > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralReplyExpired + } + raw, err := json.Marshal(action) + if err != nil { + return false, fmt.Errorf("marshal ephemeral callback action: %w", err) + } + created, err := s.c.SetNX(ctx, ephemeralCallbackActionKey(action.QueryID), raw, ttl).Result() + if err != nil { + return false, fmt.Errorf("redis put ephemeral callback action: %w", err) + } + return created, nil +} + +func (s *EphemeralMessageStore) GetEphemeralCallbackAction(ctx context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return domain.EphemeralCallbackAction{}, false, nil + } + key := ephemeralCallbackActionKey(queryID) + raw, err := s.c.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return domain.EphemeralCallbackAction{}, false, nil + } + if err != nil { + return domain.EphemeralCallbackAction{}, false, fmt.Errorf("redis get ephemeral callback action: %w", err) + } + var action domain.EphemeralCallbackAction + if strictUnmarshalEphemeral(raw, &action) != nil || action.QueryID != queryID || action.BotUserID != botUserID || + action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel || action.Peer.ID <= 0 || action.MessageID <= 0 || + action.Device.UserID != action.UserID || !now.Before(action.ExpiresAt) { + _ = s.c.Del(ctx, key).Err() + return domain.EphemeralCallbackAction{}, false, nil + } + return action, true, nil +} + +var createEphemeralMessageScript = redis.NewScript(` +local index = redis.call('GET', KEYS[2]) +if index then + local separator = string.find(index, '\n', 1, true) + if not separator then + return {4, ''} + end + local target = string.sub(index, 1, separator - 1) + local payload_hash = string.sub(index, separator + 1) + local existing = redis.call('GET', target) + if existing then + if payload_hash ~= ARGV[2] then + return {2, ''} + end + return {1, existing} + end + redis.call('DEL', KEYS[2]) +end +if redis.call('EXISTS', KEYS[1]) ~= 0 then + return {3, ''} +end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) +redis.call('SET', KEYS[2], KEYS[1] .. '\n' .. ARGV[2], 'PX', ARGV[3]) +return {0, ARGV[1]} +`) + +func (s *EphemeralMessageStore) CreateEphemeralMessage(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) { + if s == nil || s.c == nil { + return domain.EphemeralMessage{}, false, errors.New("redis ephemeral store is not configured") + } + now := message.CreatedAt + if err := message.ValidateForCreate(now); err != nil { + return domain.EphemeralMessage{}, false, err + } + ttl := message.ExpiresAt.Sub(now) + if ttl <= 0 || ttl > domain.EphemeralMessageRetention { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + raw, err := marshalEphemeralMessage(message) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + value, err := createEphemeralMessageScript.Run(ctx, s.c, []string{ + ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message), + }, raw, hex.EncodeToString(message.PayloadHash[:]), ttl.Milliseconds()).Result() + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis create ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + switch status { + case 0, 1: + stored, err := unmarshalEphemeralMessage(encoded) + return stored, status == 0, err + case 2: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict + case 3: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision + default: + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral create index is corrupt") + } +} + +func (s *EphemeralMessageStore) GetEphemeralMessage(ctx context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) { + if s == nil || s.c == nil || peer.ID <= 0 || id <= 0 { + return domain.EphemeralMessage{}, false, nil + } + raw, err := s.c.Get(ctx, ephemeralMessageKey(peer, id)).Bytes() + if errors.Is(err, redis.Nil) { + return domain.EphemeralMessage{}, false, nil + } + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis get ephemeral message: %w", err) + } + message, err := unmarshalEphemeralMessage(raw) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if message.Peer != peer || message.ID != id { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral message identity mismatch") + } + if message.Expired(now) { + return domain.EphemeralMessage{}, false, nil + } + return message, true, nil +} + +var editEphemeralMessageScript = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, ''} +end +local decoded, record = pcall(cjson.decode, raw) +if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then + return {4, ''} +end +if record.Deleted == true then + return {2, raw} +end +if tonumber(record.Version) ~= tonumber(ARGV[1]) then + return {3, raw} +end +if redis.call('PTTL', KEYS[1]) <= 0 then + return {4, ''} +end +redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +return {1, ARGV[2]} +`) + +func (s *EphemeralMessageStore) EditEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) { + current, found, err := s.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, err + } + if !found { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if current.Deleted { + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + } + if expectedVersion == 0 || current.Version != expectedVersion { + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + } + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + current.Content = content + current.EditDate = editDate + current.Version++ + replacement, err := marshalEphemeralMessage(current) + if err != nil { + return domain.EphemeralMessage{}, err + } + value, err := editEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result() + if err != nil { + return domain.EphemeralMessage{}, fmt.Errorf("redis edit ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, err + } + switch status { + case 1: + return unmarshalEphemeralMessage(encoded) + case 0: + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + case 2: + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + case 3: + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + default: + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral edit record is corrupt") + } +} + +var deleteEphemeralMessageScript = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, ''} +end +local decoded, record = pcall(cjson.decode, raw) +if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then + return {4, ''} +end +if record.Deleted == true then + return {2, raw} +end +if tonumber(record.Version) ~= tonumber(ARGV[1]) then + return {3, raw} +end +if redis.call('PTTL', KEYS[1]) <= 0 then + return {4, ''} +end +redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +return {1, ARGV[2]} +`) + +func (s *EphemeralMessageStore) DeleteEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) { + current, found, err := s.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + } + if current.Deleted { + return current, false, nil + } + if expectedVersion == 0 || current.Version != expectedVersion { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + } + current.Deleted = true + current.Version++ + current.Content = domain.EphemeralContent{} + replacement, err := marshalEphemeralMessage(current) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + value, err := deleteEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result() + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis delete ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + switch status { + case 1, 2: + message, err := unmarshalEphemeralMessage(encoded) + return message, status == 1, err + case 0: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + case 3: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + default: + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral delete record is corrupt") + } +} + +func (*EphemeralMessageStore) PruneExpiredEphemeralMessages(context.Context, time.Time, int) (int, error) { + // Redis key expiry is the authoritative O(1) cleanup path; no key scan is + // permitted here because SCAN cost would grow with total ephemeral volume. + return 0, nil +} + +func marshalEphemeralMessage(message domain.EphemeralMessage) ([]byte, error) { + raw, err := json.Marshal(message) + if err != nil { + return nil, fmt.Errorf("marshal ephemeral message: %w", err) + } + if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes { + return nil, domain.ErrEphemeralInvalid + } + return raw, nil +} + +func unmarshalEphemeralMessage(raw []byte) (domain.EphemeralMessage, error) { + if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes { + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message has invalid encoded size") + } + var message domain.EphemeralMessage + if err := strictUnmarshalEphemeral(raw, &message); err != nil { + return domain.EphemeralMessage{}, fmt.Errorf("decode redis ephemeral message: %w", err) + } + if message.ValidateStored() != nil { + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message violates stored invariants") + } + return message, nil +} + +func strictUnmarshalEphemeral(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("trailing ephemeral JSON") + } + return nil +} + +func decodeEphemeralScriptResult(value any) (int64, []byte, error) { + items, ok := value.([]interface{}) + if !ok || len(items) != 2 { + return 0, nil, fmt.Errorf("redis ephemeral script returned %T", value) + } + status, ok := items[0].(int64) + if !ok { + return 0, nil, fmt.Errorf("redis ephemeral script returned invalid status %T", items[0]) + } + var raw []byte + switch value := items[1].(type) { + case string: + raw = []byte(value) + case []byte: + raw = append([]byte(nil), value...) + case nil: + default: + return 0, nil, fmt.Errorf("redis ephemeral script returned invalid payload %T", items[1]) + } + return status, raw, nil +} diff --git a/internal/store/redisstore/ephemeral_integration_test.go b/internal/store/redisstore/ephemeral_integration_test.go new file mode 100644 index 00000000..d2693275 --- /dev/null +++ b/internal/store/redisstore/ephemeral_integration_test.go @@ -0,0 +1,99 @@ +package redisstore + +import ( + "context" + "crypto/sha256" + "os" + "testing" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestRedisEphemeralAtomicLifecycleCallbackAndBroker(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + client := redis.NewClient(&redis.Options{Addr: addr}) + defer client.Close() + ctx := context.Background() + storeImpl := NewEphemeralMessageStore(client) + now := time.Now() + seed := now.UnixNano() & 0x3fffffff + message := domain.EphemeralMessage{ + ID: int(seed) + 1, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: seed + 2}, + SenderUserID: seed + 3, ReceiverUserID: seed + 4, Date: int(now.Unix()), RandomID: seed + 5, + Content: domain.EphemeralContent{Message: "/private"}, PayloadHash: sha256.Sum256([]byte("payload")), + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + t.Cleanup(func() { + _ = client.Del(context.Background(), ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message), ephemeralCallbackActionKey(seed+6)).Err() + }) + created, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message) + if err != nil || !fresh || created.ID != message.ID { + t.Fatalf("create=%+v fresh=%v err=%v", created, fresh, err) + } + replay, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message) + if err != nil || fresh || replay.ID != message.ID { + t.Fatalf("replay=%+v fresh=%v err=%v", replay, fresh, err) + } + edited, err := storeImpl.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, message.Date+1, now) + if err != nil || edited.Version != 2 || edited.Content.Message != "edited" { + t.Fatalf("edit=%+v err=%v", edited, err) + } + deleted, changed, err := storeImpl.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now) + if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 { + t.Fatalf("delete=%+v changed=%v err=%v", deleted, changed, err) + } + + action := domain.EphemeralCallbackAction{ + QueryID: seed + 6, BotUserID: seed + 3, UserID: seed + 4, Peer: message.Peer, + MessageID: message.ID, TopMessageID: 42, + Device: domain.EphemeralDevice{UserID: seed + 4, BusinessAuthKeyID: [8]byte{7}, SessionID: 8}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := storeImpl.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put callback created=%v err=%v", created, err) + } + got, found, err := storeImpl.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now) + if err != nil || !found || got.TopMessageID != 42 || got.Device.BusinessAuthKeyID != action.Device.BusinessAuthKeyID { + t.Fatalf("callback=%+v found=%v err=%v", got, found, err) + } + + brokerCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + received := make(chan store.EphemeralPush, 1) + go func() { + _ = storeImpl.SubscribeEphemeralPushes(brokerCtx, func(_ context.Context, event store.EphemeralPush) { + select { + case received <- event: + default: + } + }) + }() + event := store.EphemeralPush{ + SourceID: "redis-test", Kind: store.EphemeralPushDelete, + TargetUserID: message.ReceiverUserID, Message: deleted, Date: int(now.Unix()), + } + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + if err := storeImpl.PublishEphemeralPush(ctx, event); err != nil { + t.Fatal(err) + } + select { + case got := <-received: + if got.SourceID != event.SourceID || got.Message.ID != event.Message.ID || got.Kind != event.Kind { + t.Fatalf("broker event=%+v", got) + } + return + case <-brokerCtx.Done(): + t.Fatal("redis ephemeral broker did not deliver") + case <-ticker.C: + } + } +} diff --git a/internal/store/redisstore/user_cache.go b/internal/store/redisstore/user_cache.go index 092ea10b..3470470f 100644 --- a/internal/store/redisstore/user_cache.go +++ b/internal/store/redisstore/user_cache.go @@ -47,9 +47,10 @@ type userBaseValue struct { BotInfoVersion int `json:"bot_info_version,omitempty"` // premium / emoji status 同理必须随缓存往返:丢失会让缓存命中路径把 // 会员输出成非会员,跨路径状态漂移(与 bot 列同一坑位)。 - PremiumUntil int `json:"premium_until,omitempty"` - EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` - EmojiStatusUntil int `json:"emoji_status_until,omitempty"` + PremiumUntil int `json:"premium_until,omitempty"` + EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` + EmojiStatusUntil int `json:"emoji_status_until,omitempty"` + EmojiStatusCollectible domain.EmojiStatusCollectible `json:"emoji_status_collectible,omitempty"` // birthday / personal channel 同理必须随缓存往返:缓存命中路径丢失会让刚保存的 // 生日 / 个人频道在重新打开资料时归零(与 bot/premium 列同一坑位)。 BirthdayDay int `json:"birthday_day,omitempty"` @@ -82,6 +83,7 @@ func baseValueFromUser(u domain.User) userBaseValue { PremiumUntil: u.PremiumUntil, EmojiStatusDocumentID: u.EmojiStatusDocumentID, EmojiStatusUntil: u.EmojiStatusUntil, + EmojiStatusCollectible: u.EmojiStatusCollectible, BirthdayDay: u.Birthday.Day, BirthdayMonth: u.Birthday.Month, BirthdayYear: u.Birthday.Year, @@ -98,23 +100,24 @@ func baseValueFromUser(u domain.User) userBaseValue { func (v userBaseValue) user() domain.User { return domain.User{ - ID: v.ID, - AccessHash: v.AccessHash, - Phone: v.Phone, - FirstName: v.FirstName, - LastName: v.LastName, - About: v.About, - Username: v.Username, - CountryCode: v.CountryCode, - Verified: v.Verified, - Support: v.Support, - Bot: v.Bot, - BotInfoVersion: v.BotInfoVersion, - PremiumUntil: v.PremiumUntil, - EmojiStatusDocumentID: v.EmojiStatusDocumentID, - EmojiStatusUntil: v.EmojiStatusUntil, - Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, - PersonalChannelID: v.PersonalChannelID, + ID: v.ID, + AccessHash: v.AccessHash, + Phone: v.Phone, + FirstName: v.FirstName, + LastName: v.LastName, + About: v.About, + Username: v.Username, + CountryCode: v.CountryCode, + Verified: v.Verified, + Support: v.Support, + Bot: v.Bot, + BotInfoVersion: v.BotInfoVersion, + PremiumUntil: v.PremiumUntil, + EmojiStatusDocumentID: v.EmojiStatusDocumentID, + EmojiStatusUntil: v.EmojiStatusUntil, + EmojiStatusCollectible: v.EmojiStatusCollectible, + Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, + PersonalChannelID: v.PersonalChannelID, Color: domain.PeerColor{ HasColor: v.ColorSet, Color: v.Color, diff --git a/internal/store/star_gift.go b/internal/store/star_gift.go index 0fff4bfc..957bf4d0 100644 --- a/internal/store/star_gift.go +++ b/internal/store/star_gift.go @@ -16,6 +16,9 @@ type StarGiftStore interface { CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) // CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。 CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) + // CreateCatalogBundle atomically switches the catalog revision and optional complete + // collectible revision. It is the only write path used by official full imports. + CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) // AnimationJSON 返回当前版本的规范化 Lottie JSON,供管理后台安全预览。 @@ -28,6 +31,9 @@ type StarGiftStore interface { UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) + // ListUniqueByOwner returns active, locally owned collectibles in a bounded + // stable order. Exported/burned/transferred gifts are deliberately excluded. + ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) // Create 写一条收到的礼物实例,返回行 id;频道礼物未显式给 saved_id 时以该行 id 作为 saved_id。 Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) @@ -60,4 +66,44 @@ type StarGiftStore interface { // private service-message updates. type StarGiftUpgradeStore interface { UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) + StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) +} + +// StarGiftLifecycleStore owns transactions that span collectible ownership, listings, +// balances and service-message updates. Implementations must serialize on the saved/unique +// aggregate and return exact replays for command-key/random-id retries. +type StarGiftLifecycleStore interface { + IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) + ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error + PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) + ConvertStarGift(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) + ListResaleStarGifts(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) + UniqueStarGiftValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) + SetStarGiftListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) + TransferStarGift(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) + PurchaseResaleStarGift(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) + SendStarGiftOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) + ResolveStarGiftOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) + ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) + CraftStarGift(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) + StarGiftAuctionState(ctx context.Context, userID int64, giftID int64, slug string, now int) (domain.StarGiftAuction, error) + ActiveStarGiftAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) + StarGiftAuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) + BidStarGiftAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) + PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) + PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) + DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) + SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error + RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) + ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) + CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) + TonBalance(ctx context.Context, userID int64) (int64, error) + TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) + ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) + ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) + ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) + ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) + // SweepStarGiftLifecycle advances time-driven offer/auction aggregates and + // drains their durable notification/delivery outboxes in bounded batches. + SweepStarGiftLifecycle(ctx context.Context, now, limit int) error } diff --git a/internal/store/user.go b/internal/store/user.go index bde26518..765b2594 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -31,9 +31,9 @@ type UserStore interface { // SweepExpiredPremium 把到期(premium_expires_at <= now)的会员行清空并 // 返回清理后的用户(供推送 updateUser);单次最多处理 limit 行。 SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) - // UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除, - // until=0 表示永久)。 - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + // UpdateEmojiStatus 更新用户自定义 emoji status。零值清除;collectible + // 必须是完整且与 DocumentID 一致的不可变快照。 + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) @@ -41,6 +41,13 @@ type UserStore interface { UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) } +// UserEmojiStatusEventStore is the aggregate write boundary used by the +// account RPC in durable deployments. The user snapshot, pts event and online +// dispatch row must commit or roll back together. +type UserEmojiStatusEventStore interface { + UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) +} + // UserCache 缓存 viewer 无关的 users 表基础资料。 // 联系人备注、隐私裁剪、头像选择和 presence 不应写入该缓存。 type UserCache interface { diff --git a/internal/web/server.go b/internal/web/server.go index 59402673..bcb07c74 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -21,17 +21,19 @@ import ( ) type Config struct { - Addr string - PublicBaseURL string - AppScheme string - WebBaseURL string - AppName string - DownloadURL string - StickerSets StickerSetResolver - Users UsernameResolver - Channels PublicChannelResolver - Privacy AnonymousPrivacyResolver - Photos ProfilePhotoResolver + Addr string + PublicBaseURL string + AppScheme string + WebBaseURL string + AppName string + DownloadURL string + StickerSets StickerSetResolver + Users UsernameResolver + Channels PublicChannelResolver + Privacy AnonymousPrivacyResolver + Photos ProfilePhotoResolver + UniqueGifts UniqueStarGiftResolver + GiftWithdrawals StarGiftWithdrawalResolver } type StickerSetResolver interface { @@ -61,6 +63,15 @@ type ProfilePhotoResolver interface { GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) } +type UniqueStarGiftResolver interface { + UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) +} + +type StarGiftWithdrawalResolver interface { + ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) + CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) +} + func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) { addr := strings.TrimSpace(cfg.Addr) if addr == "" { @@ -147,17 +158,19 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { logger = zap.NewNop() } h := &handler{ - stickerSets: cfg.StickerSets, - users: cfg.Users, - channels: cfg.Channels, - privacy: cfg.Privacy, - photos: cfg.Photos, - publicBaseURL: cfg.PublicBaseURL, - appScheme: cfg.AppScheme, - webBaseURL: cfg.WebBaseURL, - appName: cfg.AppName, - downloadURL: cfg.DownloadURL, - logger: logger, + stickerSets: cfg.StickerSets, + users: cfg.Users, + channels: cfg.Channels, + privacy: cfg.Privacy, + photos: cfg.Photos, + uniqueGifts: cfg.UniqueGifts, + giftWithdrawals: cfg.GiftWithdrawals, + publicBaseURL: cfg.PublicBaseURL, + appScheme: cfg.AppScheme, + webBaseURL: cfg.WebBaseURL, + appName: cfg.AppName, + downloadURL: cfg.DownloadURL, + logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) @@ -168,23 +181,88 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addlist/{slug}", h.addList) + mux.HandleFunc("GET /nft/{slug}", h.uniqueGift) + mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift) + mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal) + mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal) mux.HandleFunc("GET /{username}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink) return publicSecurityHeaders(mux), nil } type handler struct { - stickerSets StickerSetResolver - users UsernameResolver - channels PublicChannelResolver - privacy AnonymousPrivacyResolver - photos ProfilePhotoResolver - publicBaseURL string - appScheme string - webBaseURL string - appName string - downloadURL string - logger *zap.Logger + stickerSets StickerSetResolver + users UsernameResolver + channels PublicChannelResolver + privacy AnonymousPrivacyResolver + photos ProfilePhotoResolver + uniqueGifts UniqueStarGiftResolver + giftWithdrawals StarGiftWithdrawalResolver + publicBaseURL string + appScheme string + webBaseURL string + appName string + downloadURL string + logger *zap.Logger +} + +type starGiftWithdrawalPage struct { + AppName string + Title string + Slug string + Status string + OwnerAddress string + GiftAddress string + ExpiresAt string + CanComplete bool +} + +var starGiftWithdrawalTemplate = template.Must(template.New("star-gift-withdrawal").Parse(` + +{{.Title}} · {{.AppName}}

{{.Title}}

Collectible: {{.Slug}}

+{{if .CanComplete}}

This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.

Expires: {{.ExpiresAt}}

{{else}}

Status: {{.Status}}

{{if .OwnerAddress}}

Owner address: {{.OwnerAddress}}

Gift address: {{.GiftAddress}}

{{end}}{{end}} +
`)) + +func (h *handler) starGiftWithdrawal(w http.ResponseWriter, r *http.Request) { + h.renderStarGiftWithdrawal(w, r, false) +} + +func (h *handler) completeStarGiftWithdrawal(w http.ResponseWriter, r *http.Request) { + h.renderStarGiftWithdrawal(w, r, true) +} + +func (h *handler) renderStarGiftWithdrawal(w http.ResponseWriter, r *http.Request, complete bool) { + requestID := strings.TrimSpace(r.PathValue("requestID")) + if h.giftWithdrawals == nil || requestID == "" || len(requestID) > 256 { + http.NotFound(w, r) + return + } + var withdrawal domain.StarGiftWithdrawal + var found bool + var err error + if complete { + withdrawal, err = h.giftWithdrawals.CompleteWithdrawal(r.Context(), requestID, int(time.Now().Unix())) + found = err == nil + } else { + withdrawal, found, err = h.giftWithdrawals.ResolveWithdrawal(r.Context(), requestID) + } + if err != nil || !found { + http.NotFound(w, r) + return + } + page := starGiftWithdrawalPage{AppName: h.appName, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug, + Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress, + ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339), + CanComplete: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix())} + if page.Title == "" { + page.Title = "Collectible gift export" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := starGiftWithdrawalTemplate.Execute(w, page); err != nil { + h.logger.Warn("render star gift withdrawal", zap.Error(err)) + } } func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) { @@ -224,6 +302,63 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { } } +func (h *handler) uniqueGift(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if h.uniqueGifts == nil || !validStarGiftSlugPath(slug) { + http.NotFound(w, r) + return + } + unique, found, err := h.uniqueGifts.UniqueBySlug(r.Context(), slug) + if err != nil { + h.logger.Error("Public unique star gift lookup failed", zap.String("slug", slug), zap.Error(err)) + http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + canonicalSlug := unique.Slug + if unique.ID <= 0 || unique.GiftID <= 0 || unique.Num <= 0 || + !validStarGiftSlugPath(canonicalSlug) || !strings.EqualFold(slug, canonicalSlug) || + !utf8.ValidString(unique.Title) || utf8.RuneCountInString(unique.Title) > domain.MaxStarGiftTitleRunes { + h.logger.Error("Public unique star gift resolver returned invalid aggregate", + zap.String("requested_slug", slug), zap.String("resolved_slug", canonicalSlug), + zap.Int64("unique_id", unique.ID), zap.Int64("gift_id", unique.GiftID), zap.Int("num", unique.Num)) + http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError) + return + } + if slug != canonicalSlug || strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, h.publicURL("nft", canonicalSlug), http.StatusPermanentRedirect) + return + } + title := strings.TrimSpace(unique.Title) + if title == "" { + title = "Collectible gift" + } + subtitle := fmt.Sprintf("Collectible #%d", unique.Num) + if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued { + subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal)) + } + app := h.appURL("nft", "slug", canonicalSlug) + data := pageData{ + AppName: h.appName, + Title: title, + KindLabel: "collectible gift", + Subtitle: subtitle, + Description: "This collectible was created from a gift on " + h.appName + ". Open it in the app to view its current details.", + CanonicalURL: h.publicURL("nft", canonicalSlug), + AppURL: template.URL(app), + LegacyTgURL: template.URL(legacyTgURL("nft", "slug", canonicalSlug)), + } + data.AppURLJS = template.JS(strconv.Quote(app)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") + if err := landingTemplate.Execute(w, data); err != nil { + h.logger.Error("Render public unique star gift page failed", zap.String("slug", canonicalSlug), zap.Error(err)) + } +} + func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { raw := strings.TrimSpace(r.PathValue("username")) if strings.HasPrefix(raw, "+") { @@ -910,7 +1045,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string { func publicSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -940,6 +1075,23 @@ func validSlugPath(slug string) bool { return links.ValidChatlistSlug(slug) } +func validStarGiftSlugPath(slug string) bool { + if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes { + return false + } + for _, r := range slug { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-': + default: + return false + } + } + return true +} + func validUsernamePath(username string) bool { if username == "" || len(username) < 5 || len(username) > 32 { return false diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 8593a067..248990b5 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "telesrv/internal/domain" ) @@ -44,6 +45,158 @@ func newTestHandlerWithPublicPeers( return h } +type fakeGiftWithdrawals struct { + value domain.StarGiftWithdrawal + found bool + completeCalls int +} + +type fakeUniqueGifts struct { + bySlug map[string]domain.UniqueStarGift + err error + calls int +} + +func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) { + f.calls++ + if f.err != nil { + return domain.UniqueStarGift{}, false, f.err + } + value, ok := f.bySlug[strings.ToLower(slug)] + return value, ok, nil +} + +func TestHandlerServesUniqueGiftLandingPage(t *testing.T) { + const slug = "official-5895603153683874485-7" + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + slug: { + ID: 7001, GiftID: 5895603153683874485, Title: "Official Gift", Slug: slug, Num: 7, + AvailabilityIssued: 7, AvailabilityTotal: 1000, + }, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "http://127.0.0.1:2401", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/"+slug, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + for _, want := range []string{ + "Official Gift", "Collectible #7", "7/1 000 issued", + "http://127.0.0.1:2401/nft/" + slug, + "telesrv://nft?slug=" + slug, + "tg://nft?slug=" + slug, + "Open it in the app to view its current details.", + } { + if !strings.Contains(rr.Body.String(), want) { + t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) + } + } + if strings.Contains(rr.Body.String(), `window.location.href = "tg://`) { + t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", rr.Body.String()) + } + if got := rr.Header().Get("Cache-Control"); got != "public, max-age=60, must-revalidate" { + t.Fatalf("Cache-Control = %q", got) + } +} + +func TestHandlerCanonicalizesUniqueGiftSlug(t *testing.T) { + const canonical = "Official-Gift-7" + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + strings.ToLower(canonical): {ID: 7, GiftID: 70, Slug: canonical, Num: 7}, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + for _, path := range []string{"/nft/official-gift-7", "/nft/" + canonical + "/"} { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusPermanentRedirect || rr.Header().Get("Location") != "https://telesrv.net/nft/"+canonical { + t.Fatalf("%s status=%d location=%q", path, rr.Code, rr.Header().Get("Location")) + } + } +} + +func TestHandlerRejectsInvalidMissingAndBrokenUniqueGift(t *testing.T) { + resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + "broken-1": {ID: 1, GiftID: 2, Slug: "other-1", Num: 1}, + }} + handler, err := NewHandler(Config{ + StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + for _, path := range []string{ + "/nft/missing-1", "/nft/bad!slug", "/nft/%E4%B8%AD%E6%96%87", "/nft/" + strings.Repeat("x", domain.MaxStarGiftSlugBytes+1), + } { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("%s status=%d, want 404", path, rr.Code) + } + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/broken-1", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("broken aggregate status=%d, want 500", rr.Code) + } + + resolver.err = errors.New("lookup failed") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/error-1", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("lookup error status=%d, want 500", rr.Code) + } +} + +func (f *fakeGiftWithdrawals) ResolveWithdrawal(context.Context, string) (domain.StarGiftWithdrawal, bool, error) { + return f.value, f.found, nil +} + +func (f *fakeGiftWithdrawals) CompleteWithdrawal(_ context.Context, _ string, _ int) (domain.StarGiftWithdrawal, error) { + f.completeCalls++ + f.value.Status = "completed" + f.value.Gift.OwnerAddress = "telesrv-owner:test" + f.value.Gift.GiftAddress = "telesrv-gift:test" + return f.value, nil +} + +func TestHandlerCompletesLocalStarGiftWithdrawal(t *testing.T) { + resolver := &fakeGiftWithdrawals{found: true, value: domain.StarGiftWithdrawal{ + ProviderRequestID: "safe-token", Status: "pending", ExpiresAt: int(time.Now().Add(time.Minute).Unix()), + Gift: domain.UniqueStarGift{Title: ``, Slug: "gift-1"}, + }} + handler, err := NewHandler(Config{StickerSets: fakeResolver{}, GiftWithdrawals: resolver, + PublicBaseURL: "https://telesrv.net", AppName: "telesrv"}) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/gift-withdrawal/safe-token", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Complete local export") || + strings.Contains(rr.Body.String(), ``) { + t.Fatalf("withdrawal GET status=%d body=%s", rr.Code, rr.Body.String()) + } + if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self'") { + t.Fatalf("withdrawal CSP does not allow its same-origin POST form: %q", csp) + } + + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/gift-withdrawal/safe-token", strings.NewReader(""))) + if rr.Code != http.StatusOK || resolver.completeCalls != 1 || !strings.Contains(rr.Body.String(), "Status: completed") || + !strings.Contains(rr.Body.String(), "telesrv-owner:test") || !strings.Contains(rr.Body.String(), "telesrv-gift:test") { + t.Fatalf("withdrawal POST calls=%d status=%d body=%s", resolver.completeCalls, rr.Code, rr.Body.String()) + } +} + func TestHandlerServesStickerSetLandingPage(t *testing.T) { resolver := fakeResolver{ "fresh_pack": { @@ -181,7 +334,10 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { "stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers}, "emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true}, }, - Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}}, + Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}}, + UniqueGifts: &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ + "gift-1": {ID: 1, GiftID: 10, Slug: "gift-1", Num: 1}, + }}, PublicBaseURL: "https://links.example.test", AppScheme: "example-chat", WebBaseURL: "https://web.example.test/client/", @@ -216,6 +372,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { {path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"}, {path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"}, {path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"}, + {path: "/nft/gift-1", want: "example-chat://nft?slug=gift-1"}, } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index a219a4e3..7c1bfd99 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -23,28 +23,30 @@ import ( "math/big" "github.com/fxamacker/cbor/v2" + + "telesrv/internal/branding" ) // 验证错误。调用方据此映射到 TL 错误码/拒绝登录。 var ( - ErrClientDataInvalid = errors.New("webauthn: client data invalid") - ErrChallengeMismatch = errors.New("webauthn: challenge mismatch") - ErrOriginNotAllowed = errors.New("webauthn: origin not allowed") - ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch") - ErrUserNotPresent = errors.New("webauthn: user-present flag not set") - ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid") - ErrAttestationInvalid = errors.New("webauthn: attestation object invalid") + ErrClientDataInvalid = errors.New("webauthn: client data invalid") + ErrChallengeMismatch = errors.New("webauthn: challenge mismatch") + ErrOriginNotAllowed = errors.New("webauthn: origin not allowed") + ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch") + ErrUserNotPresent = errors.New("webauthn: user-present flag not set") + ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid") + ErrAttestationInvalid = errors.New("webauthn: attestation object invalid") ErrPublicKeyUnsupported = errors.New("webauthn: unsupported public key algorithm") - ErrSignatureInvalid = errors.New("webauthn: signature invalid") - ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)") + ErrSignatureInvalid = errors.New("webauthn: signature invalid") + ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)") ) // authenticatorData 标志位(WebAuthn §6.1)。 const ( - flagUserPresent = 0x01 - flagUserVerified = 0x04 - flagAttestedCredData = 0x40 - flagExtensionData = 0x80 + flagUserPresent = 0x01 + flagUserVerified = 0x04 + flagAttestedCredData = 0x40 + flagExtensionData = 0x80 ) // COSE algorithm identifiers。 @@ -55,8 +57,8 @@ const ( // COSE key type / curve。 const ( - coseKtyOKP = 1 - coseKtyEC2 = 2 + coseKtyOKP = 1 + coseKtyEC2 = 2 coseCrvP256 = 1 coseCrvEd25519 = 6 ) @@ -76,7 +78,7 @@ func decodeB64URL(s string) ([]byte, error) { type RegistrationParams struct { RPID string RPName string - UserID []byte // 通常是 "dcId:userId" 的字节 + UserID []byte // 通常是 "dcId:userId" 的字节 UserName string UserDisplay string Challenge []byte @@ -99,7 +101,7 @@ func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) { exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)}) } pub := map[string]any{ - "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, "Telegram")}, + "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName)}, "user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)}, "challenge": b64.EncodeToString(p.Challenge), "pubKeyCredParams": []map[string]any{ @@ -199,12 +201,12 @@ func ChallengeFromClientData(clientDataJSON []byte) ([]byte, error) { // parsedAuthData 是 authenticatorData 的解析结果。 type parsedAuthData struct { - rpIDHash []byte - flags byte - signCount uint32 - aaguid []byte - credID []byte - credPubKey []byte // COSE 公钥原始字节(仅注册时存在) + rpIDHash []byte + flags byte + signCount uint32 + aaguid []byte + credID []byte + credPubKey []byte // COSE 公钥原始字节(仅注册时存在) } func parseAuthData(authData []byte) (parsedAuthData, error) { @@ -351,8 +353,8 @@ func VerifyAssertion(in VerifyAssertionInput) (uint32, error) { // publicKey 抽象 ES256/EdDSA 验签。 type publicKey struct { - ec *ecdsa.PublicKey - ed ed25519.PublicKey + ec *ecdsa.PublicKey + ed ed25519.PublicKey } func (p publicKey) verify(signed, sig []byte) error {