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

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

View file

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

View file

@ -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 的用户。
## 项目特性
| 状态 | 特性 | 说明 |

View file

@ -1,5 +1,5 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
// 生成 telesrv 外观 seed(Default_appearance_seed.json + Default_wallpapers/{documents,thumbs/m}/*.dat)。
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
//
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
}
api := client.API()
docsDir := filepath.Join(outDir, "Default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m")
docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
return appearance.Document{}, err
}
sum := sha256.Sum256(data)
out.Path = "Default_wallpapers/documents/" + name
out.Path = "default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图
for _, t := range doc.Thumbs {
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
Path: "Default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
})
break
}
@ -373,7 +373,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors))
catalog := appearance.Catalog{
Source: "official telegram (appearancefetch)",
Source: "upstream appearance snapshot",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Notes: appearance.Notes{
Server: "official",
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
if err != nil {
return err
}
jsonPath := filepath.Join(outDir, "Default_appearance_seed.json")
jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err
}

View file

@ -0,0 +1,78 @@
# aiogram 3 echo demo
该示例使用标准 aiogram 3 API仅把 API server 指向 telesrv。aiogram 的
`TelegramAPIServer.from_base()` 会自动拼出 telesrv 已支持的
`/bot<TOKEN>/<method>``/file/bot<TOKEN>/<path>`
```powershell
python -m pip install -r .\cmd\bots\aiogramecho\requirements.txt
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
python .\cmd\bots\aiogramecho\echo.py --drop-pending
```
## Ephemeral echoBot API 10.2
先通过 `setMyCommands``private` 注册为 `is_ephemeral=true`,再在 TDesktop Layer 228
的群组中发送:
```text
/private@你的Bot用户名
```
aiogram 3.30.0 原生解析 `ephemeral_message_id`。示例在 15 秒 action 窗口内携带
`receiver_user_id``ReplyParameters(ephemeral_message_id=...)` 回复
`ephemeral echo: ...`失败不会降级成普通消息。Alice 应看到两条带
可见性提示的消息自己发出的命令显示“Only visible to @Botbot 回复显示
“Only visible to you”Bob 不应看到其中任何一条。
发送三种语义色的 reply keyboard 与 inline callback 按钮:
```powershell
python .\cmd\bots\aiogramecho\echo.py `
--buttons-chat-id 1780243200 `
--drop-pending
```
可选的 `--button-icon-id <custom_emoji_document_id>` 同时验证按钮自定义 emoji
图标。Telegram 官方会按 bot owner Premium / Fragment 权限限制图标使用;颜色只接受
`primary`(蓝)、`success`(绿)、`danger`(红),不接受任意 RGB。
只主动发消息、不启动轮询:
```powershell
python .\cmd\bots\aiogramecho\echo.py `
--send-only `
--send-chat-id 1780243200 `
--send-text "hello from aiogram"
```
默认 API server 是 `http://127.0.0.1:8081`,可用 `--base-url`
`TELESRV_BOT_API_SERVER` 覆盖。不要在这里追加 `/bot`;这与 ptbecho 的
`--base-url http://127.0.0.1:8081/bot` 参数格式不同。
轮询模式会回答本示例的 `aiogram-*` callback也会对同一测试 bot 先前由
其它 demo 创建的 inline callback 给出兜底确认,避免 TDesktop 按钮一直转圈。
## Webhook 模式
telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx
后推进 `update_id`。aiogram 可监听本机 HTTP由 Caddy/Nginx/Tunnel 提供公网 HTTPS
```powershell
$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook"
$env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret"
python .\cmd\bots\aiogramecho\echo.py `
--mode webhook `
--listen-host 127.0.0.1 `
--listen-port 8082 `
--webhook-path /webhook `
--drop-pending
```
公网 URL 必须是 HTTPS端口限 Telegram 标准的 443/80/88/8443本机监听地址
可以是 HTTP因为 TLS 通常在反向代理终止。`secret_token` 会由 telesrv 放入
`X-Telegram-Bot-Api-Secret-Token`aiogram 会自动校验。若希望进程退出时删除配置,
再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。
同一个 token 的 polling 与 webhook 互斥;切回轮询时直接以默认模式启动,示例会先
调用 `deleteWebhook`,再开始 `getUpdates`

View file

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""aiogram 3 echo/button demo for the telesrv Bot API endpoint."""
import argparse
import asyncio
import logging
import os
from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.enums import ButtonStyle
from aiogram.filters import Command, CommandStart
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
Message,
ReplyParameters,
ReplyKeyboardMarkup,
)
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
LOG = logging.getLogger("aiogramecho")
def env_int(name: str) -> int | None:
raw = os.getenv(name)
if not raw:
return None
try:
return int(raw)
except ValueError as exc:
raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="aiogram 3 echo bot against telesrv")
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"))
parser.add_argument(
"--base-url",
default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"),
help="API server origin; aiogram adds /bot<TOKEN> and /file/bot<TOKEN>",
)
parser.add_argument("--prefix", default="aiogram echo: ")
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
parser.add_argument("--drop-pending", action="store_true")
parser.add_argument("--mode", choices=("polling", "webhook"), default=os.getenv("TELESRV_BOT_MODE", "polling"))
parser.add_argument(
"--webhook-url",
default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""),
help="Public HTTPS URL including the webhook path",
)
parser.add_argument(
"--webhook-path",
default=os.getenv("TELESRV_BOT_WEBHOOK_PATH", "/webhook"),
help="Local aiohttp route, normally the path part of --webhook-url",
)
parser.add_argument("--webhook-secret", default=os.getenv("TELESRV_BOT_WEBHOOK_SECRET", "telesrv-aiogram-demo"))
parser.add_argument("--listen-host", default=os.getenv("TELESRV_BOT_LISTEN_HOST", "127.0.0.1"))
parser.add_argument("--listen-port", type=int, default=env_int("TELESRV_BOT_LISTEN_PORT") or 8082)
parser.add_argument("--delete-webhook-on-exit", action="store_true")
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--send-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_CHAT_ID"))
parser.add_argument("--send-text", default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", ""))
parser.add_argument("--buttons-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"))
parser.add_argument(
"--button-icon-id",
default=os.getenv("TELESRV_BOT_DEMO_BUTTON_ICON_ID"),
help="Optional custom emoji document id used as the button icon",
)
parser.add_argument("--send-only", action="store_true")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
if not args.token:
parser.error("missing --token or TELESRV_BOT_TOKEN")
if args.timeout < 0 or args.timeout > 50:
parser.error("--timeout must be between 0 and 50")
if args.send_text and args.send_chat_id is None:
parser.error("--send-chat-id is required with --send-text")
if args.send_only and not args.send_text and args.buttons_chat_id is None:
parser.error("--send-only requires --send-text or --buttons-chat-id")
if args.button_icon_id:
try:
if int(args.button_icon_id) <= 0:
raise ValueError
except ValueError as exc:
raise SystemExit("--button-icon-id must be a positive integer") from exc
if args.mode == "webhook" and not args.webhook_url:
parser.error("--webhook-url or TELESRV_BOT_WEBHOOK_URL is required in webhook mode")
if not args.webhook_path.startswith("/") or "?" in args.webhook_path or "#" in args.webhook_path:
parser.error("--webhook-path must be an absolute path without query or fragment")
if args.listen_port < 1 or args.listen_port > 65535:
parser.error("--listen-port must be between 1 and 65535")
return args
def reply_keyboard(icon_id: str | None) -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="Primary", style=ButtonStyle.PRIMARY, icon_custom_emoji_id=icon_id),
KeyboardButton(text="Success", style=ButtonStyle.SUCCESS, icon_custom_emoji_id=icon_id),
KeyboardButton(text="Danger", style=ButtonStyle.DANGER, icon_custom_emoji_id=icon_id),
]
],
resize_keyboard=True,
one_time_keyboard=True,
input_field_placeholder="Tap a colored reply button",
)
def inline_keyboard(icon_id: str | None) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Primary",
callback_data="aiogram-primary",
style=ButtonStyle.PRIMARY,
icon_custom_emoji_id=icon_id,
),
InlineKeyboardButton(
text="Success",
callback_data="aiogram-success",
style=ButtonStyle.SUCCESS,
icon_custom_emoji_id=icon_id,
),
InlineKeyboardButton(
text="Danger",
callback_data="aiogram-danger",
style=ButtonStyle.DANGER,
icon_custom_emoji_id=icon_id,
),
]
]
)
async def send_button_messages(bot: Bot, chat_id: int, icon_id: str | None) -> None:
reply = await bot.send_message(
chat_id=chat_id,
text="TELESRV_AIOGRAM_REPLY_STYLES_20260719",
reply_markup=reply_keyboard(icon_id),
)
inline = await bot.send_message(
chat_id=chat_id,
text="TELESRV_AIOGRAM_INLINE_STYLES_20260719",
reply_markup=inline_keyboard(icon_id),
)
LOG.info(
"sent styled buttons chat_id=%s reply_message_id=%s inline_message_id=%s",
chat_id,
reply.message_id,
inline.message_id,
)
def ephemeral_message_id(message: object) -> int | None:
"""Read the native aiogram field, retaining an extra-field fallback."""
raw = getattr(message, "ephemeral_message_id", None)
if raw is None:
raw = (getattr(message, "model_extra", None) or {}).get("ephemeral_message_id")
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
return raw
return None
async def send_echo(message: Message, prefix: str, ephemeral_prefix: str):
text = message.text or message.caption or ""
if not text:
return None
transient_id = ephemeral_message_id(message)
if transient_id is None:
return await message.answer(prefix + text)
if message.from_user is None:
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
return None
return await message.bot.send_message(
chat_id=message.chat.id,
text=ephemeral_prefix + text,
receiver_user_id=message.from_user.id,
reply_parameters=ReplyParameters(ephemeral_message_id=transient_id),
)
def build_dispatcher(args: argparse.Namespace) -> Dispatcher:
router = Router(name="telesrv-aiogramecho")
@router.message(CommandStart())
async def start(message: Message) -> None:
await message.answer("send /ping <text>, /buttons, or any private text")
@router.message(Command("buttons"))
async def buttons(message: Message) -> None:
await send_button_messages(message.bot, message.chat.id, args.button_icon_id)
@router.message(Command("ping"))
async def ping(message: Message) -> None:
await send_echo(message, args.prefix, args.ephemeral_prefix)
@router.message(Command("private"))
async def private(message: Message) -> None:
sent = await send_echo(message, args.prefix, args.ephemeral_prefix)
LOG.info(
"echoed ephemeral chat_id=%s ephemeral_message_id=%s sent_ephemeral_message_id=%s",
message.chat.id,
ephemeral_message_id(message),
ephemeral_message_id(sent) if sent is not None else None,
)
@router.callback_query(F.data.startswith("aiogram-"))
async def callback(query: CallbackQuery) -> None:
await query.answer(f"telesrv {query.data} callback OK")
LOG.info("answered callback query_id=%s data=%r", query.id, query.data)
@router.callback_query()
async def fallback_callback(query: CallbackQuery) -> None:
"""Keep the echo demo responsive for buttons created by another demo."""
await query.answer("telesrv callback OK")
LOG.info("answered fallback callback query_id=%s data=%r", query.id, query.data)
@router.message(F.text)
async def echo(message: Message) -> None:
await send_echo(message, args.prefix, args.ephemeral_prefix)
LOG.info("echoed chat_id=%s message_id=%s", message.chat.id, message.message_id)
dispatcher = Dispatcher()
dispatcher.include_router(router)
return dispatcher
def build_bot(args: argparse.Namespace) -> Bot:
session = AiohttpSession(api=TelegramAPIServer.from_base(args.base_url.rstrip("/")))
return Bot(token=args.token, session=session)
async def run(args: argparse.Namespace) -> None:
bot = build_bot(args)
runner: web.AppRunner | None = None
try:
me = await bot.get_me()
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
if args.send_chat_id is not None and args.send_text:
sent = await bot.send_message(chat_id=args.send_chat_id, text=args.send_text)
LOG.info("sent proactive chat_id=%s message_id=%s", args.send_chat_id, sent.message_id)
if args.buttons_chat_id is not None:
await send_button_messages(bot, args.buttons_chat_id, args.button_icon_id)
if args.send_only:
return
dispatcher = build_dispatcher(args)
allowed_updates = ["message", "edited_message", "callback_query"]
if args.mode == "polling":
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
await dispatcher.start_polling(
bot,
allowed_updates=allowed_updates,
polling_timeout=args.timeout,
close_bot_session=False,
)
return
application = web.Application()
SimpleRequestHandler(
dispatcher=dispatcher,
bot=bot,
secret_token=args.webhook_secret,
).register(application, path=args.webhook_path)
setup_application(application, dispatcher, bot=bot)
await bot.set_webhook(
url=args.webhook_url,
secret_token=args.webhook_secret,
allowed_updates=allowed_updates,
drop_pending_updates=args.drop_pending,
)
runner = web.AppRunner(application)
await runner.setup()
site = web.TCPSite(runner, host=args.listen_host, port=args.listen_port)
await site.start()
LOG.info(
"webhook listening on http://%s:%s%s, public_url=%s",
args.listen_host,
args.listen_port,
args.webhook_path,
args.webhook_url,
)
await asyncio.Event().wait()
finally:
if args.mode == "webhook" and args.delete_webhook_on_exit:
try:
await bot.delete_webhook()
except Exception: # pragma: no cover - best-effort shutdown logging
LOG.exception("failed to delete webhook during shutdown")
if runner is not None:
await runner.cleanup()
await bot.session.close()
def main() -> int:
args = parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
asyncio.run(run(args))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

@ -0,0 +1,63 @@
import unittest
from types import SimpleNamespace
import echo
class FakeBot:
def __init__(self):
self.calls = []
async def send_message(self, **kwargs):
self.calls.append(kwargs)
return SimpleNamespace(message_id=0, ephemeral_message_id=88)
class FakeMessage:
def __init__(self, *, text="hello", ephemeral_id=None):
self.text = text
self.caption = None
self.chat = SimpleNamespace(id=-1000000000002)
self.from_user = SimpleNamespace(id=1780243200)
self.ephemeral_message_id = ephemeral_id
self.model_extra = {}
self.bot = FakeBot()
self.answers = []
async def answer(self, text):
self.answers.append(text)
return SimpleNamespace(message_id=1, ephemeral_message_id=None)
class EchoTest(unittest.IsolatedAsyncioTestCase):
async def test_ephemeral_echo_uses_receiver_and_transient_reply(self):
message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77)
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
self.assertEqual(message.answers, [])
self.assertEqual(len(message.bot.calls), 1)
call = message.bot.calls[0]
self.assertEqual(call["chat_id"], -1000000000002)
self.assertEqual(call["text"], "ephemeral echo: /private@TetrisBot")
self.assertEqual(call["receiver_user_id"], 1780243200)
self.assertEqual(call["reply_parameters"].ephemeral_message_id, 77)
async def test_normal_echo_stays_on_standard_answer_path(self):
message = FakeMessage()
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
self.assertEqual(message.answers, ["aiogram echo: hello"])
self.assertEqual(message.bot.calls, [])
def test_extra_field_fallback_and_invalid_values(self):
message = FakeMessage()
message.model_extra = {"ephemeral_message_id": 66}
self.assertEqual(echo.ephemeral_message_id(message), 66)
message.model_extra = {"ephemeral_message_id": True}
self.assertIsNone(echo.ephemeral_message_id(message))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,50 @@
# Bedolaga formatted-text demo
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
```python
Bot(
...,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
```
因此 `/start``message.answer()` 不显式传 `parse_mode`,仍会由 aiogram 自动向
telesrv 发送 `parse_mode=HTML``/formatdemo` 依次发送默认 HTML、legacy Markdown、
MarkdownV2用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
TDesktop` 链路。
## 安装
建议使用虚拟环境token 只通过环境变量传入:
```powershell
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
-r .\cmd\bots\bedolagaformat\requirements.txt
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
$env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py --drop-pending
```
随后在 TDesktop 中向 bot 发送:
```text
/start
/formatdemo
```
也可以不启动 polling直接向指定私聊发送三条格式测试消息
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py `
--send-only `
--send-chat-id 1780243200 `
--marker BEDOLAGA-LOCAL-VERIFY
```
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token也不会
把 token 写入文件。

View file

@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Bedolaga-style aiogram formatted-text demo for the telesrv Bot API.
The bot factory intentionally mirrors remnawave-bedolaga-telegram-bot:
DefaultBotProperties(parse_mode=ParseMode.HTML) is installed globally, while
individual sends may override it with legacy Markdown or MarkdownV2.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
import os
import re
from aiogram import Bot, Dispatcher, Router
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.enums import ParseMode
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
LOG = logging.getLogger("bedolagaformat")
MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])")
@dataclass(frozen=True)
class FormatSample:
name: str
text: str
parse_mode: ParseMode | None
def default_marker() -> str:
now = datetime.now(timezone.utc)
return now.strftime("BEDOLAGA%Y%m%dT%H%M%SZ")
def escape_markdown_v2_text(value: str) -> str:
return MARKDOWN_V2_RESERVED_RE.sub(r"\\\1", value)
def format_samples(marker: str) -> tuple[FormatSample, ...]:
"""Return deterministic messages whose labels are safe in every grammar."""
markdown_v2_marker = escape_markdown_v2_text(marker)
return (
FormatSample(
name="default_html",
text=(
f"<b>{marker} Default HTML</b> "
"<i>italic 😀</i> <u>underline</u> "
"<tg-spoiler>spoiler</tg-spoiler> "
'<a href="https://example.com/bedolaga">link</a>'
),
# Deliberately omitted from send_message: the Bedolaga factory default
# must inject HTML just as it does for message.answer() in start.py.
parse_mode=None,
),
FormatSample(
name="markdown",
text=(
f"*{marker} Markdown* _italic 😀_ "
"[link](https://example.com/bedolaga) `code`"
),
parse_mode=ParseMode.MARKDOWN,
),
FormatSample(
name="markdown_v2",
text=(
f"*{markdown_v2_marker} MarkdownV2* _italic 😀_ __underline__ "
"~strike~ ||spoiler|| "
"[link](https://example.com/bedolaga) `code`"
),
parse_mode=ParseMode.MARKDOWN_V2,
),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bedolaga-style aiogram HTML/Markdown demo against telesrv"
)
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"))
parser.add_argument(
"--base-url",
default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"),
help="API server origin; do not append /bot<TOKEN>",
)
parser.add_argument(
"--send-chat-id",
type=int,
default=int(os.environ["TELESRV_BOT_DEMO_CHAT_ID"])
if os.getenv("TELESRV_BOT_DEMO_CHAT_ID")
else None,
help="Send the complete suite proactively before polling",
)
parser.add_argument("--send-only", action="store_true")
parser.add_argument("--drop-pending", action="store_true")
parser.add_argument("--polling-timeout", type=int, default=10)
parser.add_argument("--marker", default=default_marker())
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
if not args.token:
parser.error("missing --token or TELESRV_BOT_TOKEN")
if args.send_only and args.send_chat_id is None:
parser.error("--send-only requires --send-chat-id")
if not MARKER_RE.fullmatch(args.marker):
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
if not 0 <= args.polling_timeout <= 50:
parser.error("--polling-timeout must be between 0 and 50")
return args
def create_bot(token: str, base_url: str) -> Bot:
"""Mirror Bedolaga's create_bot() with a custom Telegram API server."""
session = AiohttpSession(
api=TelegramAPIServer.from_base(base_url.rstrip("/"))
)
return Bot(
token=token,
session=session,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
message_ids: list[int] = []
for sample in format_samples(marker):
if sample.parse_mode is None:
sent = await bot.send_message(chat_id=chat_id, text=sample.text)
else:
sent = await bot.send_message(
chat_id=chat_id,
text=sample.text,
parse_mode=sample.parse_mode,
)
message_ids.append(sent.message_id)
LOG.info(
"sent sample=%s chat_id=%s message_id=%s parse_mode=%s",
sample.name,
chat_id,
sent.message_id,
sample.parse_mode.value if sample.parse_mode is not None else "default-html",
)
return message_ids
def build_dispatcher(marker: str) -> Dispatcher:
router = Router(name="telesrv-bedolaga-format")
@router.message(CommandStart())
async def start(message: Message) -> None:
# No parse_mode argument: this is the exact failure shape from Bedolaga's
# start handler when the Bot factory installs default HTML globally.
await message.answer(
f"<b>{marker} Start OK</b> <i>default HTML inherited</i> 😀"
)
LOG.info("handled /start chat_id=%s incoming_message_id=%s", message.chat.id, message.message_id)
@router.message(Command("formatdemo"))
async def format_demo(message: Message) -> None:
ids = await send_format_suite(message.bot, message.chat.id, marker)
LOG.info(
"handled /formatdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
message.chat.id,
message.message_id,
ids,
)
dispatcher = Dispatcher()
dispatcher.include_router(router)
return dispatcher
async def run(args: argparse.Namespace) -> None:
bot = create_bot(args.token, args.base_url)
try:
me = await bot.get_me()
LOG.info(
"authenticated bot_id=%s username=@%s bot_api=%s marker=%s",
me.id,
me.username or "",
args.base_url,
args.marker,
)
if args.send_chat_id is not None:
await send_format_suite(bot, args.send_chat_id, args.marker)
if args.send_only:
return
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
dispatcher = build_dispatcher(args.marker)
LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id)
await dispatcher.start_polling(
bot,
allowed_updates=["message"],
polling_timeout=args.polling_timeout,
close_bot_session=False,
)
finally:
await bot.session.close()
def main() -> int:
args = parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
asyncio.run(run(args))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

@ -0,0 +1,52 @@
import importlib.util
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock
from aiogram.enums import ParseMode
MODULE_PATH = Path(__file__).with_name("demo.py")
SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
demo = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = demo
SPEC.loader.exec_module(demo)
class SentMessage:
def __init__(self, message_id: int) -> None:
self.message_id = message_id
class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
def test_format_samples_cover_default_and_explicit_modes(self) -> None:
samples = demo.format_samples("BEDOLAGA123")
self.assertEqual(
[sample.parse_mode for sample in samples],
[None, ParseMode.MARKDOWN, ParseMode.MARKDOWN_V2],
)
self.assertIn("<b>BEDOLAGA123 Default HTML</b>", samples[0].text)
self.assertIn("*BEDOLAGA123 Markdown*", samples[1].text)
self.assertIn("*BEDOLAGA123 MarkdownV2*", samples[2].text)
def test_markdown_v2_escapes_reserved_marker_characters(self) -> None:
samples = demo.format_samples("BEDOLAGA-FULL-20260720")
self.assertIn(r"BEDOLAGA\-FULL\-20260720", samples[2].text)
async def test_send_suite_omits_parse_mode_only_for_default_html(self) -> None:
bot = AsyncMock()
bot.send_message.side_effect = [SentMessage(11), SentMessage(12), SentMessage(13)]
message_ids = await demo.send_format_suite(bot, 1780243200, "BEDOLAGA123")
self.assertEqual(message_ids, [11, 12, 13])
calls = bot.send_message.await_args_list
self.assertNotIn("parse_mode", calls[0].kwargs)
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
if __name__ == "__main__":
unittest.main()

View file

@ -17,6 +17,21 @@ In a group with BotFather privacy enabled, send a command such as:
/ping hello from group
```
## Ephemeral echoBot API 10.2
先通过 `setMyCommands``private` 注册为 `is_ephemeral=true`,然后保持本示例轮询。
在 TDesktop Layer 228 的群组里发送:
```text
/private@你的Bot用户名
```
本示例从 PTB 的 `Message.api_kwargs` 读取 `ephemeral_message_id`,并在 15 秒 action
窗口内用 `receiver_user_id``reply_parameters.ephemeral_message_id` 回复
`ephemeral echo: ...`。请求失败时不会降级成普通群消息。Alice 应看到两条带
可见性提示的消息自己发出的命令显示“Only visible to @Botbot 回复显示
“Only visible to you”Bob 不应看到其中任何一条。
主动发送一条消息并退出:
```powershell
@ -34,11 +49,24 @@ python .\cmd\bots\ptbecho\echo.py `
--send-text "ptbecho is online"
```
发送 reply keyboard 与 inline callback 两条验证消息并保持 polling
```powershell
python .\cmd\bots\ptbecho\echo.py `
--buttons-chat-id 1780243200
```
reply keyboard 与 inline keyboard 都会各显示蓝/绿/红三种语义色;点击 reply
button 会按普通文本消息进入 echo 链,点击 inline button 会由 `callback_query` handler 调用
`answerCallbackQuery` 并显示 `telesrv inline callback OK`。也可以在私聊中发送
`/buttons` 生成同样的两条消息。
可选参数:
- `--send-count N`:连续主动发送 N 条。
- `--send-interval SEC`:连续发送之间的间隔。
- `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。
- `--buttons-chat-id` / `TELESRV_BOT_DEMO_BUTTONS_CHAT_ID`:发送两类键盘验证消息并监听 callback。
本地超级群 chat id 使用 Bot API 形式 `-100<channel_id>`;例如 channel id 为
`2` 时是 `-1000000000002`

View file

@ -16,6 +16,14 @@ to the bot, mentions, or messages otherwise visible to bots. In a group, send:
/ping hello
For a command registered with ``is_ephemeral=true``, send:
/private@YourBotUsername
The incoming Bot API message has ``message_id=0`` and carries the transient
identifier in ``api_kwargs`` until python-telegram-bot exposes the Bot API 10.2
fields directly. The demo replies through the same ephemeral action window.
The same program can also send proactive messages:
python cmd/bots/ptbecho/echo.py \
@ -32,11 +40,18 @@ import os
import signal
from typing import Iterable
from telegram import Bot, Update
from telegram.constants import ChatAction
from telegram import (
Bot,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
Update,
)
from telegram.ext import (
Application,
ApplicationBuilder,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
@ -63,6 +78,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--base-url", default=os.getenv("TELESRV_BOT_API_BASE_URL", "http://127.0.0.1:8081/bot"))
parser.add_argument("--base-file-url", default=os.getenv("TELESRV_BOT_API_BASE_FILE_URL", "http://127.0.0.1:8081/file/bot"))
parser.add_argument("--prefix", default="echo: ")
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
parser.add_argument("--drop-pending", action="store_true", help="Drop pending updates before polling")
parser.add_argument("--timeout", type=int, default=30, help="getUpdates long-poll timeout seconds")
parser.add_argument(
@ -78,6 +94,12 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send")
parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends")
parser.add_argument(
"--buttons-chat-id",
type=int,
default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"),
help="Send reply/inline keyboard validation messages to this chat on startup",
)
parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
@ -87,11 +109,10 @@ def parse_args() -> argparse.Namespace:
parser.error("--send-count must be >= 1")
if args.send_interval < 0:
parser.error("--send-interval must be >= 0")
wants_send = args.send_only or bool(args.send_text)
if wants_send and args.send_chat_id is None:
if args.send_text and args.send_chat_id is None:
parser.error("--send-chat-id is required when --send-text or --send-only is used")
if args.send_only and not args.send_text:
parser.error("--send-only requires --send-text")
if args.send_only and not args.send_text and args.buttons_chat_id is None:
parser.error("--send-only requires --send-text or --buttons-chat-id")
return args
@ -105,6 +126,38 @@ async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await echo(update, context)
def ephemeral_message_id(message: object) -> int | None:
"""Read a Bot API 10.2 field without depending on a PTB release cycle."""
raw = getattr(message, "ephemeral_message_id", None)
if raw is None:
raw = (getattr(message, "api_kwargs", None) or {}).get("ephemeral_message_id")
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
return raw
return None
async def send_echo(message: object, bot: Bot, prefix: str, ephemeral_prefix: str):
text = getattr(message, "text", None) or getattr(message, "caption", None) or ""
if not text:
return None
transient_id = ephemeral_message_id(message)
if transient_id is None:
return await message.reply_text(prefix + text)
sender = getattr(message, "from_user", None)
if sender is None:
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
return None
return await bot.send_message(
chat_id=message.chat_id,
text=ephemeral_prefix + text,
api_kwargs={
"receiver_user_id": sender.id,
"reply_parameters": {"ephemeral_message_id": transient_id},
},
)
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_message is None or update.effective_chat is None:
return
@ -112,18 +165,44 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not text:
return
prefix = context.application.bot_data.get("prefix", "echo: ")
await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING)
sent = await update.effective_message.reply_text(prefix + text)
ephemeral_prefix = context.application.bot_data.get("ephemeral_prefix", "ephemeral echo: ")
transient_id = ephemeral_message_id(update.effective_message)
sent = await send_echo(update.effective_message, context.bot, prefix, ephemeral_prefix)
if sent is None:
return
LOG.info(
"echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r",
"echoed update_id=%s chat_id=%s message_id=%s ephemeral_message_id=%s "
"sent_message_id=%s sent_ephemeral_message_id=%s text=%r",
update.update_id,
update.effective_chat.id,
update.effective_message.message_id,
transient_id,
sent.message_id,
ephemeral_message_id(sent),
text,
)
async def buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_chat is None:
return
await send_button_messages(context.bot, update.effective_chat.id)
async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query is None:
return
await query.answer("telesrv inline callback OK")
LOG.info(
"answered callback query_id=%s chat_id=%s message_id=%s data=%r",
query.id,
query.message.chat_id if query.message else None,
query.message.message_id if query.message else None,
query.data,
)
async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
for index in range(count):
sent = await bot.send_message(chat_id=chat_id, text=text)
@ -139,11 +218,44 @@ 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
if chat_id is not None and text:
await send_active_messages(
app.bot,
chat_id=chat_id,
@ -151,13 +263,18 @@ async def send_on_startup(app: Application) -> None:
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,6 +307,7 @@ 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)
if args.send_chat_id is not None and args.send_text:
await send_active_messages(
bot,
chat_id=args.send_chat_id,
@ -192,11 +315,13 @@ async def run_send_only(args: argparse.Namespace) -> None:
count=args.send_count,
interval=args.send_interval,
)
if args.buttons_chat_id is not None:
await send_button_messages(bot, args.buttons_chat_id)
def stop_signals() -> Iterable[int]:
def stop_signals() -> Iterable[int] | None:
if os.name == "nt":
return (signal.SIGINT, signal.SIGTERM)
return None
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
@ -214,7 +339,7 @@ def main() -> int:
app = build_app(args)
app.run_polling(
allowed_updates=["message", "edited_message"],
allowed_updates=["message", "edited_message", "callback_query"],
drop_pending_updates=args.drop_pending,
poll_interval=0.0,
timeout=args.timeout,

View file

@ -0,0 +1,70 @@
import unittest
from types import SimpleNamespace
import echo
class FakeMessage:
def __init__(self, *, text="hello", ephemeral_id=None):
self.text = text
self.caption = None
self.chat_id = -1000000000002
self.from_user = SimpleNamespace(id=1780243200)
self.api_kwargs = {}
if ephemeral_id is not None:
self.api_kwargs["ephemeral_message_id"] = ephemeral_id
self.replies = []
async def reply_text(self, text):
self.replies.append(text)
return SimpleNamespace(message_id=1, api_kwargs={})
class FakeBot:
def __init__(self):
self.calls = []
async def send_message(self, **kwargs):
self.calls.append(kwargs)
return SimpleNamespace(
message_id=0,
api_kwargs={"ephemeral_message_id": 88},
)
class EchoTest(unittest.IsolatedAsyncioTestCase):
async def test_ephemeral_echo_uses_receiver_and_transient_reply(self):
message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77)
bot = FakeBot()
await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ")
self.assertEqual(message.replies, [])
self.assertEqual(
bot.calls,
[{
"chat_id": -1000000000002,
"text": "ephemeral echo: /private@TetrisBot",
"api_kwargs": {
"receiver_user_id": 1780243200,
"reply_parameters": {"ephemeral_message_id": 77},
},
}],
)
async def test_normal_echo_stays_on_standard_reply_path(self):
message = FakeMessage()
bot = FakeBot()
await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ")
self.assertEqual(message.replies, ["echo: hello"])
self.assertEqual(bot.calls, [])
def test_invalid_ephemeral_id_is_not_accepted(self):
self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=0)))
self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=True)))
if __name__ == "__main__":
unittest.main()

1042
cmd/giftfetch/main.go Normal file

File diff suppressed because it is too large Load diff

238
cmd/giftfetch/main_test.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
)
func TestHasRenderableStickerAttribute(t *testing.T) {
tests := []struct {
name string
attributes []tg.DocumentAttributeClass
want bool
}{
{name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true},
{name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true},
{name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want {
t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want)
}
})
}
}
func TestDocumentExtension(t *testing.T) {
tests := []struct {
name string
mime string
want string
}{
{name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"},
{name: "", mime: "application/x-tgsticker", want: ".tgs"},
{name: "unsafe.exe", mime: "video/webm", want: ".webm"},
{name: "", mime: "application/octet-stream", want: ".bin"},
}
for _, test := range tests {
if got := documentExtension(test.name, test.mime); got != test.want {
t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want)
}
}
}
func TestBoundedBuffer(t *testing.T) {
buffer := &boundedBuffer{max: 4}
if _, err := buffer.Write([]byte("abc")); err != nil {
t.Fatal(err)
}
if written, err := buffer.Write([]byte("def")); err == nil || written != 1 {
t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err)
}
if !bytes.Equal(buffer.Bytes(), []byte("abcd")) {
t.Fatalf("buffer = %q, want abcd", buffer.Bytes())
}
}
func TestDownloadPartSize(t *testing.T) {
tests := []struct {
size int64
want int
}{
{size: 1, want: 4 << 10},
{size: (4 << 10) - 1, want: 4 << 10},
{size: 4 << 10, want: 8 << 10},
{size: (512 << 10) - 1, want: 512 << 10},
{size: 512 << 10, want: 512 << 10},
{size: 1 << 20, want: 512 << 10},
}
for _, test := range tests {
if got := downloadPartSize(test.size); got != test.want {
t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want)
}
}
}
func TestParseAllowedMissingThumbs(t *testing.T) {
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
if err != nil {
t.Fatal(err)
}
if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") {
t.Fatalf("allowed = %v", allowed)
}
for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} {
if _, err := parseAllowedMissingThumbs(invalid); err == nil {
t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid)
}
}
}
func TestExistingArtifact(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil {
t.Fatal(err)
}
data, reused, err := existingArtifact(root, "resource.bin", 4, 16)
if err != nil || !reused || string(data) != "gift" {
t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err)
}
if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused {
t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err)
}
if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused {
t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err)
}
}
func TestReadTLArtifact(t *testing.T) {
root := t.TempDir()
var encoded bin.Buffer
if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil {
t.Fatal(err)
}
var decoded tg.PaymentsStarGiftUpgradeAttributes
artifact, err := readTLArtifact(root, "attributes.tl", &decoded)
if err != nil {
t.Fatal(err)
}
if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" {
t.Fatalf("artifact = %+v", artifact)
}
if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil {
t.Fatal("expected trailing-byte error")
}
}
func TestCollectUpgradeableGiftIDs(t *testing.T) {
classes := []tg.StarGiftClass{
&tg.StarGift{ID: 1, UpgradeStars: 10},
&tg.StarGift{ID: 2, UpgradeVariants: 3},
&tg.StarGift{ID: 3},
&tg.StarGiftUnique{ID: 4, GiftID: 1},
}
got := collectUpgradeableGiftIDs(classes)
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got)
}
}
func TestCollectUpgradeAttributes(t *testing.T) {
modelDoc := testGiftDocument(101)
patternDoc := testGiftDocument(102)
model := &tg.StarGiftAttributeModel{
Name: "Crafted model",
Document: modelDoc,
Rarity: &tg.StarGiftAttributeRarityLegendary{},
}
model.SetCrafted(true)
result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
model,
&tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}},
&tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}},
}}
added := make(map[int64]string)
set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
doc, ok := class.(*tg.Document)
if !ok {
return nil, errors.New("not a document")
}
added[doc.ID] = purpose
return doc, nil
})
if err != nil {
t.Fatal(err)
}
if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 {
t.Fatalf("unexpected attribute counts: %+v", set)
}
if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" {
t.Fatalf("model = %+v", set.Models[0])
}
if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 {
t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity)
}
if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" {
t.Fatalf("backdrop = %+v", set.Backdrops[0])
}
if len(set.DocumentIDs) != 2 || len(added) != 2 {
t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added)
}
}
func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) {
_, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
&tg.StarGiftAttributeOriginalDetails{},
}}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) {
return nil, nil
})
if err == nil {
t.Fatal("expected unsupported-constructor error")
}
}
func TestCollectRarityKinds(t *testing.T) {
tests := []struct {
class tg.StarGiftAttributeRarityClass
kind string
}{
{class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"},
{class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"},
{class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"},
{class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"},
}
for _, test := range tests {
got, err := collectRarity(test.class)
if err != nil || got.Kind != test.kind || got.ConstructorID == "" {
t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err)
}
}
if _, err := collectRarity(nil); err == nil {
t.Fatal("expected nil-rarity error")
}
}
func testGiftDocument(id int64) *tg.Document {
return &tg.Document{
ID: id,
Size: 1,
MimeType: "application/x-tgsticker",
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeCustomEmoji{Alt: "gift"},
},
}
}

View file

@ -134,23 +134,23 @@ type ChannelDetail struct {
}
type StarGiftRow struct {
GiftID int64
RevisionID int64
GiftID int64 `json:"GiftID,string"`
RevisionID int64 `json:"RevisionID,string"`
Revision int
Title string
Stars int64
ConvertStars int64
Stars int64 `json:"Stars,string"`
ConvertStars int64 `json:"ConvertStars,string"`
Enabled bool
SortOrder int
DocumentID int64
DocumentID int64 `json:"DocumentID,string"`
SourceName string
SourceFormat string
AnimationSHA string
AnimationSize int64
AnimationSize int64 `json:"AnimationSize,string"`
Width int
Height int
FrameRate float64
ReceivedCount int64
ReceivedCount int64 `json:"ReceivedCount,string"`
CreatedBy string
UpdatedAt time.Time
}

View file

@ -58,6 +58,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
@ -70,6 +72,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
@ -228,6 +231,19 @@ func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Re
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
}
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
}
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
@ -696,10 +712,10 @@ type importStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
@ -746,11 +762,48 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request)
writeCommandResultAPI(w, result, err)
}
type importOfficialStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SourceGiftID string `json:"source_gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
}
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importOfficialStarGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
req := admin.ImportOfficialStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
writeCommandResultAPI(w, result, err)
}
type publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UpgradeStars int64 `json:"upgrade_stars"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
Enabled bool `json:"enabled"`
}
@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
SortOrder int `json:"sort_order"`
}

View file

@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
t.Fatalf("forwarded freeze request = %+v", got)
}
}
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(StarGiftRow{
GiftID: maxInt64,
RevisionID: maxInt64,
Stars: maxInt64,
ConvertStars: maxInt64,
DocumentID: maxInt64,
AnimationSize: maxInt64,
ReceivedCount: maxInt64,
})
if err != nil {
t.Fatalf("marshal star gift row: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal star gift row: %v", err)
}
for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} {
if got[field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want exact decimal string", field, got[field])
}
}
}
func TestStarGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
"source_gift_id":"5895603153683874485",
"gift_id":"9223372036854775807",
"stars":"9223372036854775807",
"convert_stars":"9223372036854775807",
"upgrade_stars":"9223372036854775807"
}`))
var got importOfficialStarGiftAPIRequest
if err := decodeJSON(req, &got); err != nil {
t.Fatalf("decode gift action: %v", err)
}
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
t.Fatalf("decoded gift action = %+v", got)
}
}
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.SetStarGiftEnabledRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{
"reason":"precision regression","confirm":false,
"gift_id":"9223372036854775807","enabled":false
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleSetStarGiftEnabledAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun {
t.Fatalf("forwarded gift request = %+v", got)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
<script type="module" crossorigin src="/assets/index-D9dH2J7N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
</head>
<body>
<div id="root"></div>

View file

@ -8,6 +8,7 @@ import type {
GroupMessageListResponse,
MessageDetail,
MessageListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
} from "./types";
@ -66,11 +67,14 @@ export const api = {
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Language = "en" | "zh";
export type Language = "en" | "zh" | "ru";
export type TranslationParams = Record<string, string | number | boolean>;
export type TFunction = (key: string, params?: TranslationParams) => string;
@ -83,6 +83,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.logout": "Log out",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "Operations Admin",
"login.body": "Enter credentials to open the console.",
"login.secret": "Admin password or token",
@ -259,6 +260,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "Gift catalog operation",
"gifts.newRevision": "Create revision for gift #{id}",
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
"gifts.officialSource": "Official snapshot",
"gifts.fileSource": "Upload file",
"gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.",
"gifts.officialSearch": "Search official gift ID or title",
"gifts.officialSelect": "Choose an official gift",
"gifts.officialRequired": "Choose an official gift first",
"gifts.officialResults": "Showing {shown} of {total}",
"gifts.officialCategoryLabel": "Official gift capability category",
"gifts.officialCategory.all": "All",
"gifts.officialCategory.upgrade": "Upgradable",
"gifts.officialCategory.craft": "Craftable",
"gifts.officialCategory.basic": "Not upgradable",
"gifts.officialUnnamed": "Unnamed official gift #{id}",
"gifts.officialAttributes": "{count} attributes",
"gifts.canUpgrade": "Can upgrade",
"gifts.cannotUpgrade": "Cannot upgrade",
"gifts.canCraft": "Can Craft",
"gifts.cannotCraft": "Cannot Craft",
"gifts.officialEmpty": "No official gifts match this category and search.",
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
"gifts.animation": "Animation file",
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
@ -306,8 +327,8 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "Pattern",
"collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰",
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
"collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.",
"collectibles.colorHint": "Colors are stored as 24-bit RGB values.",
"collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute",
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
@ -432,6 +453,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.logout": "退出",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "运维后台",
"login.body": "输入凭据后进入控制台。",
"login.secret": "管理员密码或 token",
@ -608,6 +630,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 TGS。",
"gifts.officialSource": "官方资源库",
"gifts.fileSource": "上传文件",
"gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。",
"gifts.officialSearch": "搜索官方礼物 ID 或标题",
"gifts.officialSelect": "请选择官方礼物",
"gifts.officialRequired": "请先选择一个官方礼物",
"gifts.officialResults": "显示 {shown} / {total} 项",
"gifts.officialCategoryLabel": "官方礼物能力分类",
"gifts.officialCategory.all": "全部",
"gifts.officialCategory.upgrade": "可升级",
"gifts.officialCategory.craft": "可 Craft",
"gifts.officialCategory.basic": "不可升级",
"gifts.officialUnnamed": "未命名官方礼物 #{id}",
"gifts.officialAttributes": "{count} 个属性",
"gifts.canUpgrade": "可升级",
"gifts.cannotUpgrade": "不可升级",
"gifts.canCraft": "可 Craft",
"gifts.cannotCraft": "不可 Craft",
"gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。",
"gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)",
"gifts.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验",
@ -655,8 +697,8 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "图案",
"collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
"collectibles.colorHint": "颜色会按 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性",
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
@ -704,6 +746,376 @@ const translations: Record<Language, Record<string, string>> = {
"audit.dryRun": "预演",
"audit.reason": "原因",
"audit.time": "时间"
},
ru: {
"app.adminConsole": "Панель администратора",
"app.localAccess": "Локальный доступ",
"app.title": "telesrv admin",
"common.actions": "Действия",
"common.admins": "Администраторы",
"common.backToList": "Назад к списку",
"common.channel": "Канал",
"common.channelOrGroup": "Канал / Группа",
"common.clear": "Очистить",
"common.close": "Закрыть",
"common.count": "Количество",
"common.deleted": "Удалено",
"common.detail": "Детали",
"common.device": "Устройство",
"common.disabled": "Отключено",
"common.enabled": "Включено",
"common.fromPeer": "От пира",
"common.group": "Группа",
"common.id": "ID",
"common.limit": "Лимит",
"common.loading": "Загрузка...",
"common.member": "Участник",
"common.members": "Участники",
"common.messageId": "ID сообщения",
"common.name": "Имя",
"common.no": "Нет",
"common.noResults": "Нет результатов",
"common.none": "Нет",
"common.normal": "Обычный",
"common.operations": "Операции",
"common.owner": "Владелец",
"common.platform": "Платформа",
"common.refresh": "Обновить",
"common.search": "Поиск",
"common.sender": "Отправитель",
"common.status": "Статус",
"common.survived": "Уцелело",
"common.time": "Время",
"common.type": "Тип",
"common.updatedAt": "Обновлено",
"common.username": "Имя пользователя",
"common.valid": "Действителен",
"common.verified": "Подтвержден",
"common.views": "Просмотры",
"common.yes": "Да",
"route.accounts": "Аккаунты",
"route.accountsSubtitle": "Консоль / Аккаунты",
"route.channels": "Супергруппы и каналы",
"route.channelsSubtitle": "Консоль / Каналы",
"route.dashboard": "Панель управления",
"route.dashboardSubtitle": "Консоль / Обзор",
"route.messages": "Аудит сообщений",
"route.messagesSubtitle": "Консоль / Сообщения",
"route.gifts": "Звездные подарки",
"route.giftsSubtitle": "Консоль / Звездные подарки",
"layout.navigation": "Навигация",
"layout.primaryNav": "Основное меню",
"layout.dashboard": "Обзор",
"layout.accounts": "Аккаунты",
"layout.channels": "Супергруппы / Каналы",
"layout.messages": "Сообщения",
"layout.gifts": "Звездные подарки",
"layout.privateMessages": "Личные",
"layout.groupMessages": "Группы",
"layout.runtime": "Среда выполнения",
"layout.adminBackend": "Админ-бэкенд",
"layout.ready": "Готов",
"layout.pgRead": "Чтение из PG",
"layout.readOnly": "Только чтение",
"layout.writeOps": "Операции записи",
"layout.dryRun": "Тестовый запуск",
"layout.actor": "Вход выполнен как: {actor}",
"layout.logout": "Выйти",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "Панель администратора",
"login.body": "Введите учетные данные для входа в консоль.",
"login.secret": "Пароль или токен администратора",
"login.submit": "Войти",
"login.submitting": "Вход...",
"dashboard.eyebrow": "Состояние системы",
"dashboard.title": "Обзор консоли",
"dashboard.readPath": "Путь чтения",
"dashboard.readPathValue": "PG только для чтения",
"dashboard.writePath": "Путь записи",
"dashboard.executionPolicy": "Политика выполнения",
"dashboard.dryRunFirst": "Сначала тестовый запуск",
"dashboard.accountsText": "Статус аккаунтов, премиум, верификация, сессии.",
"dashboard.channelsText": "Публичные каналы и группы, количество участников, статус верификации.",
"dashboard.messagesText": "Ящики сообщений, обновления, состояние исходящих.",
"dashboard.strip.dryRun": "Все опасные действия начинаются с тестового запуска",
"dashboard.strip.token": "Браузер никогда не сохраняет внутренние токены",
"dashboard.strip.pagination": "Списки используют курсорную пагинацию",
"dashboard.strip.snapshot": "Детальные страницы сохраняют моментальные снимки исходного состояния",
"account.pageTitle": "Аккаунты",
"account.queryResults": "Результаты поиска",
"account.recentActive": "Недавно активные аккаунты",
"account.currentPage": "Аккаунты на странице",
"account.onlineDevices": "Активные сессии устройств",
"account.premium": "Premium",
"account.frozen": "Заморожен",
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",
"account.userID": "ID пользователя",
"account.phone": "Телефон",
"account.lastActive": "Последняя активность",
"account.notVerified": "Не подтвержден",
"account.notPremium": "Без Premium",
"account.premiumUntil": "Premium истекает",
"account.starsBalance": "Баланс Звезд",
"account.startingGrantApplied": "стартовый бонус начислен",
"account.startingGrantPending": "ожидает стартового бонуса",
"account.activeSessions": "Авторизованные устройства",
"account.accountFlags": "Флаги аккаунта",
"account.restriction": "Ограничение",
"account.restricted": "Ограничен",
"account.createdAt": "Создан",
"account.detailTitle": "Аккаунт #{id}",
"account.profile": "Профиль аккаунта",
"account.loadingDetail": "Загрузка данных аккаунта",
"account.waitingData": "Ожидание данных",
"account.noUsername": "Нет имени пользователя",
"account.noPhone": "Нет телефона",
"account.accountFrozen": "Аккаунт заморожен",
"account.accountActive": "Аккаунт активен",
"account.authorizationsTitle": "Авторизованные устройства",
"account.authorizationsCount": "Авторизаций: {count}",
"account.recentAdminOps": "Последние действия администратора",
"account.recent30Audit": "Последние 30 записей аудита",
"account.actionDock": "Действия с аккаунтом",
"account.freezeAccount": "Заморозить аккаунт",
"account.updateFreeze": "Обновить параметры заморозки",
"account.unfreezeAccount": "Разморозить аккаунт",
"account.freezeSince": "Заморожен с",
"account.freezeUntil": "Срок подачи апелляции",
"account.freezeUntilAria": "Срок подачи апелляции на заморозку",
"account.freezeAppealURL": "URL для апелляции",
"account.freezeAppealURLAria": "URL для апелляции на заморозку",
"account.premiumMonths": "Срок действия Premium (в месяцах)",
"account.premiumMonthsAria": "Указать срок действия Premium в месяцах",
"account.setPremium": "Выдать Premium",
"account.clearPremium": "Снять Premium",
"account.starsAmount": "Количество звёзд",
"account.starsAmountAria": "Указать количество начисляемых звёзд",
"account.grantStars": "Начислить звёзды",
"account.setVerified": "Подтвердить аккаунт",
"account.clearVerified": "Снять подтверждение",
"channel.pageTitle": "Супергруппы и каналы",
"channel.recentUpdated": "Недавно обновленные",
"channel.currentPage": "Объекты на странице",
"channel.megagroups": "Супергруппы",
"channel.broadcasts": "Каналы",
"channel.verifiedCount": "Подтверждено",
"channel.searchPlaceholder": "ID канала / имя пользователя / название",
"channel.channelID": "ID канала",
"channel.kind": "Тип",
"channel.title": "Название",
"channel.pts": "PTS",
"channel.detailProfile": "Профиль канала",
"channel.loadingDetail": "Загрузка данных канала",
"channel.creator": "Создатель: {id}",
"channel.governance": "Модерация",
"channel.governanceValue": "Заблокировано {banned} / Исключено {kicked}",
"channel.flags": "Флаги канала",
"channel.rawRow": "Исходная строка БД",
"channel.rawRowText": "Снимок базы данных только для чтения",
"channel.actionDock": "Действия с каналом",
"channel.setVerified": "Подтвердить канал",
"channel.clearVerified": "Снять подтверждение",
"channel.kind.broadcast": "Канал",
"channel.kind.forum": "Супергруппа / Форум",
"channel.kind.megagroup": "Супергруппа",
"channel.kind.generic": "Канал / Группа",
"messages.privateTitle": "Личные сообщения",
"messages.privateEyebrow": "Личные ящики сообщений",
"messages.groupTitle": "Групповые сообщения",
"messages.groupEyebrow": "Сообщения супергрупп и каналов",
"messages.selectPrivatePeers": "Сначала найдите и выберите владельца и собеседника",
"messages.selectChannel": "Сначала найдите и выберите супергруппу или канал",
"messages.ownerUser": "Пользователь-владелец",
"messages.peerUser": "Собеседник",
"messages.beforeDatePlaceholder": "курсор before_date",
"messages.beforeIDPlaceholder": "курсор before_msg_id",
"messages.limitPlaceholder": "лимит <= 100",
"messages.searchMessages": "Поиск сообщений",
"messages.nextPage": "Следующая страница",
"messages.currentPage": "Сообщения на странице",
"messages.deleted": "Удалено",
"messages.outgoing": "Исходящее",
"messages.incoming": "Входящее",
"messages.ownerPeer": "Владелец / Собеседник",
"messages.deleteSelected": "Указать и удалить выбранные сообщения",
"messages.idsPlaceholder": "ID сообщений через запятую",
"messages.revoke": "Удалить для обеих сторон",
"messages.previewDelete": "Тестовое удаление",
"messages.clearHistory": "Очистить историю личной переписки",
"messages.maxIDPlaceholder": "граница max_id",
"messages.maxBatchesPlaceholder": "max_batches",
"messages.justClear": "Очистить только у себя",
"messages.previewClearHistory": "Тестовая очистка истории",
"messages.direction": "Направление",
"messages.body": "Текст сообщения",
"messages.privateDetailTitle": "Сообщение #{id}",
"messages.detailEyebrow": "Детали сообщения",
"messages.backPrivate": "Назад к личным сообщениям",
"messages.backGroup": "Назад к групповым сообщениям",
"messages.ownerPeerTitle": "Владелец {owner} · Собеседник {peer}",
"messages.senderSubtitle": "Отправитель {sender} · {date}",
"messages.boxID": "ID ящика сообщений",
"messages.privateMessageID": "ID личного сообщения",
"messages.messageSender": "Отправитель сообщения",
"messages.messageBox": "Ящик сообщений",
"messages.dialogRow": "Строка диалога",
"messages.privateRow": "Строка личного сообщения",
"messages.channelMessageRow": "Строка сообщения канала",
"messages.channelRow": "Строка канала",
"messages.userUpdateEvents": "События обновления пользователей",
"messages.channelUpdateEvents": "События обновления каналов",
"messages.eventJson": "JSON события",
"messages.dispatchOutbox": "Очередь отправки (Outbox)",
"messages.messageBoxesSnapshot": "Снимок message_boxes только для чтения",
"messages.dialogSnapshot": "Снимок dialogs только для чтения",
"messages.privateSnapshot": "Снимок private_messages только для чтения",
"messages.channelMessagesSnapshot": "Снимок channel_messages только для чтения",
"messages.channelSnapshot": "Снимок channels только для чтения",
"messages.userEventsSource": "постоянные user_update_events",
"messages.channelEventsSource": "постоянные channel_update_events",
"messages.outboxSource": "онлайн/офлайн dispatch_outbox",
"messages.attempts": "Попытки",
"messages.deleteThis": "Удалить это сообщение",
"messages.groupDetailTitle": "Групповое сообщение #{id}",
"messages.channelGroupTitle": "Канал / Группа {id}",
"messages.mediaCount": "С медиафайлами",
"messages.channelPosts": "Посты канала",
"messages.channelGroup": "Канал / Группа",
"messages.pinned": "Закреплено",
"messages.channelPost": "Пост в канале",
"gifts.pageTitle": "Каталог звездных подарков",
"gifts.eyebrow": "Каталог, неизменяемые версии и файлы анимаций",
"gifts.total": "Подарков в каталоге",
"gifts.enabled": "Включено",
"gifts.received": "Полученные подарки",
"gifts.formats": "Поддерживаемые форматы",
"gifts.add": "Добавить подарок",
"gifts.searchPlaceholder": "Поиск по ID подарка, названию или формату",
"gifts.listSummary": "Показано {shown} из {total}",
"gifts.idRevision": "ID / Версия",
"gifts.price": "Цена / Конвертация",
"gifts.importTitle": "Импорт звездного подарка",
"gifts.importEyebrow": "Управление каталогом подарков",
"gifts.newRevision": "Создать версию для подарка #{id}",
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
"gifts.officialSource": "Официальный снимок",
"gifts.fileSource": "Загрузить файл",
"gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.",
"gifts.officialSearch": "Поиск по ID или названию официального подарка",
"gifts.officialSelect": "Выберите официальный подарок",
"gifts.officialRequired": "Сначала выберите официальный подарок",
"gifts.officialResults": "Показано {shown} из {total}",
"gifts.officialCategoryLabel": "Категория возможностей официального подарка",
"gifts.officialCategory.all": "Все",
"gifts.officialCategory.upgrade": "Можно улучшить",
"gifts.officialCategory.craft": "Можно создать",
"gifts.officialCategory.basic": "Нельзя улучшить",
"gifts.officialUnnamed": "Официальный подарок без названия #{id}",
"gifts.officialAttributes": "Атрибутов: {count}",
"gifts.canUpgrade": "Можно улучшить",
"gifts.cannotUpgrade": "Нельзя улучшить",
"gifts.canCraft": "Можно создать",
"gifts.cannotCraft": "Нельзя создать",
"gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.",
"gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели",
"gifts.animation": "Файл анимации",
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
"gifts.chooseFile": "Выбрать файл",
"gifts.changeFile": "Изменить файл",
"gifts.title": "Отображаемое название",
"gifts.titlePlaceholder": "например, Праздничная звезда",
"gifts.stars": "Цена в Звездах",
"gifts.convertStars": "Звезд при конвертации",
"gifts.sortOrder": "Порядок сортировки",
"gifts.reason": "Причина для аудита",
"gifts.reasonPlaceholder": "Кратко опишите причину импорта этого подарка",
"gifts.enableAfterImport": "Включить после импорта",
"gifts.validate": "Тестовая проверка",
"gifts.confirmImport": "Подтвердить импорт",
"gifts.stepDetails": "Файл и описание",
"gifts.stepValidate": "Тестовая проверка",
"gifts.stepImport": "Подтверждение импорта",
"gifts.fileRequired": "Сначала выберите файл TGS или Lottie",
"gifts.source": "Источник",
"gifts.replace": "Новая версия",
"gifts.disable": "Отключить",
"gifts.enable": "Включить",
"gifts.empty": "Звездные подарки еще не импортированы.",
"gifts.emptyHint": "Импортируйте первую анимацию, чтобы начать наполнение каталога.",
"gifts.validationReady": "Проверка пройдена",
"gifts.validationHint": "Проверьте нормализованные метаданные и подтвердите импорт.",
"gifts.confirmState": "Применить проверенные изменения состояния к подарку #{id}?",
"collectibles.manage": "Пул атрибутов",
"collectibles.title": "Пул коллекционных предметов · Подарок #{id}",
"collectibles.eyebrow": "Уникальные атрибуты подарка",
"collectibles.activeRevision": "Опубликованная версия {revision}",
"collectibles.published": "Опубликовано",
"collectibles.noPool": "Нет опубликованного пула коллекционных предметов",
"collectibles.noPoolHint": "Опубликуйте модели, узоры и фоны для активации улучшений.",
"collectibles.publishNew": "Опубликовать новую неизменяемую версию",
"collectibles.immutableHint": "Тестовый запуск проверяет каждый файл и итоговые показатели редкости перед тем, как версия станет активной.",
"collectibles.upgradeStars": "Цена улучшения в Звездах",
"collectibles.supply": "Уникальный тираж",
"collectibles.slug": "Публичный префикс ссылки (slug)",
"collectibles.models": "Модели",
"collectibles.patterns": "Узоры",
"collectibles.backdrops": "Фоны",
"collectibles.model": "Модель",
"collectibles.pattern": "Узор",
"collectibles.backdrop": "Фон",
"collectibles.rarity": "Редкость ‰",
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения.",
"collectibles.addAttribute": "Добавить",
"collectibles.remove": "Удалить атрибут",
"collectibles.fileRequired": "Для каждой модели и узора требуется файл TGS или Lottie.",
"collectibles.backdropID": "ID фона",
"collectibles.color.center": "Центр",
"collectibles.color.edge": "Край",
"collectibles.color.pattern": "Узор",
"collectibles.color.text": "Текст",
"collectibles.validationReady": "Пул атрибутов корректен",
"collectibles.validationHint": "Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.",
"collectibles.publish": "Опубликовать версию",
"messages.msgIDsInvalid": "Некорректные ID сообщений",
"auth.device": "Устройство",
"auth.platform": "Платформа",
"auth.ip": "IP-адрес",
"auth.lastActive": "Последняя активность",
"auth.revokeCurrent": "Отозвать текущую",
"auth.keepCurrent": "Оставить текущую",
"auth.revokeAll": "Разлогинить все устройства",
"picker.userPlaceholder": "Поиск по user_id / телефону / имени пользователя",
"picker.channelPlaceholder": "Поиск по channel_id / имени пользователя / названию",
"picker.verified": "Подтвержденные",
"picker.regular": "Обычные",
"action.reasonRequired": "Пожалуйста, укажите причину операции",
"action.flow": "Процесс выполнения",
"action.close": "Закрыть",
"action.stepReason": "Укажите причину",
"action.stepDryRun": "Тестовый запуск",
"action.stepConfirm": "Подтверждение выполнения",
"action.reason": "Причина операции",
"action.reasonPlaceholder": "Опишите, почему выполняется эта операция",
"action.requestPreview": "Запросить предпросмотр",
"action.result": "Результат действия",
"action.commandID": "ID команды",
"action.status": "Статус",
"action.dryRun": "Тестовый запуск",
"action.runAgain": "Запустить тестовый запуск снова",
"action.runDry": "Сначала выполните тестовый запуск",
"action.confirm": "Подтвердить выполнение",
"audit.id": "ID",
"audit.commandID": "ID команды",
"audit.action": "Действие",
"audit.actor": "Исполнитель",
"audit.status": "Статус",
"audit.dryRun": "Тестовый запуск",
"audit.reason": "Причина",
"audit.time": "Время"
}
};
@ -724,7 +1136,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
} catch {
// Language persistence is best-effort.
}
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
const langAttr = lang === "zh" ? "zh-CN" : lang === "ru" ? "ru" : "en";
document.documentElement.lang = langAttr;
document.documentElement.dir = "ltr";
document.documentElement.setAttribute("translate", "no");
document.body.classList.add("notranslate");
@ -752,7 +1165,7 @@ export function LanguageSwitch() {
const { lang, setLang, t } = useI18n();
return (
<div className="language-switch" role="group" aria-label="Language">
{(["en", "zh"] as const).map((item) => (
{(["en", "zh", "ru"] as const).map((item) => (
<button
key={item}
className={lang === item ? "active" : ""}
@ -813,5 +1226,8 @@ function normalizeLanguage(value: string | null | undefined): Language | null {
if (tag === "en" || tag.startsWith("en-")) {
return "en";
}
if (tag === "ru" || tag.startsWith("ru-")) {
return "ru";
}
return null;
}

View file

@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
}
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
const [data, setData] = useState<AnimationData | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
}
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}` : attribute.rarity_kind;
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
@ -135,7 +136,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
form.set("metadata", JSON.stringify({
command_id: commandID, reason: reason.trim(), confirm,
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
backdrops: backdrops.map((row) => ({
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
@ -167,7 +168,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<section className="collectible-section">
<div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
</div>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
<div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}</span></div></article>)}
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
</div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}

View file

@ -7,16 +7,23 @@ import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { CommandResult, StarGiftRow } from "../types";
import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
function formatBytes(bytes: number) {
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
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: number; revision: number; compact?: boolean }) {
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);
@ -59,6 +66,20 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: number;
);
}
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
api.officialGiftAnimation(sourceGiftID).then((data) => {
if (cancelled || !host.current) return;
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
}).catch(() => undefined);
return () => { cancelled = true; player?.destroy(); };
}, [sourceGiftID]);
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
}
export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
@ -66,7 +87,16 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [giftID, setGiftID] = useState(0);
const [importSource, setImportSource] = useState<"official" | "file">("official");
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
const [officialQuery, setOfficialQuery] = useState("");
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
const [sourceGiftID, setSourceGiftID] = useState("");
const [includeCollectible, setIncludeCollectible] = useState(true);
const [upgradeStars, setUpgradeStars] = useState("0");
const [supplyTotal, setSupplyTotal] = useState("0");
const [slugPrefix, setSlugPrefix] = useState("");
const [giftID, setGiftID] = useState("0");
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
const [convertStars, setConvertStars] = useState("50");
@ -89,6 +119,29 @@ export function GiftsPage() {
useEffect(() => { void load(); }, []);
useEffect(() => {
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
}, [importOpen, importSource, officialGifts.length]);
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
const officialCategoryCounts = useMemo(() => ({
all: officialGifts.length,
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
craft: officialGifts.filter((gift) => gift.can_craft).length,
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
}), [officialGifts]);
const visibleOfficial = useMemo(() => {
const normalized = officialQuery.trim().toLowerCase();
return officialGifts.filter((gift) => {
const categoryMatches = officialCategory === "all" ||
(officialCategory === "upgrade" && gift.can_upgrade) ||
(officialCategory === "craft" && gift.can_craft) ||
(officialCategory === "basic" && !gift.can_upgrade);
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
});
}, [officialGifts, officialQuery, officialCategory]);
const visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
@ -109,8 +162,8 @@ export function GiftsPage() {
confirm,
gift_id: giftID,
title: title.trim(),
stars: Number(stars),
convert_stars: Number(convertStars),
stars,
convert_stars: convertStars,
enabled,
sort_order: Number(sortOrder)
}));
@ -118,10 +171,34 @@ export function GiftsPage() {
return form;
}
function officialPayload(confirm: boolean, commandID = "") {
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
return {
command_id: commandID, reason: reason.trim(), confirm,
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
};
}
function chooseOfficial(gift: OfficialStarGiftRow) {
setSourceGiftID(gift.source_gift_id);
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
setStars(String(gift.stars));
setConvertStars(String(gift.convert_stars));
setIncludeCollectible(gift.can_upgrade);
setUpgradeStars(gift.upgrade_stars);
setSupplyTotal(String(gift.availability_total || 1));
setSlugPrefix(`official-${gift.source_gift_id}`);
setPreview(null);
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
setPreview(await api.importGift(uploadForm(false)));
setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
@ -131,8 +208,9 @@ export function GiftsPage() {
if (!preview) return;
setBusy(true); setImportError("");
try {
await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID(0); setTitle("");
if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
else await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID("");
await load();
setImportOpen(false);
} catch (err) {
@ -141,14 +219,16 @@ export function GiftsPage() {
}
function startImport() {
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
}
function startRevision(gift: StarGiftRow) {
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
}
return (
@ -160,7 +240,7 @@ export function GiftsPage() {
<div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
</div>
<QueryPanel>
@ -193,17 +273,69 @@ export function GiftsPage() {
{importOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${(importSource === "official" ? sourceGiftID : file) ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : (importSource === "official" ? sourceGiftID : file) ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
<div className="gift-source-tabs">
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
</div>
{importSource === "official" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
</div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
</button>
))}
</div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
{visibleOfficial.map((gift) => {
const selected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${selected ? "selected" : ""}`}
type="button" role="option" aria-selected={selected} onClick={() => chooseOfficial(gift)}>
<span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
<span className="mono">#{gift.source_gift_id}</span>
</span>
<span className="official-gift-option-meta">
<span> {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
</span>
<span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
</span>
</button>;
})}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
</div>
{selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
</div>}
{selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
{includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>}
</>}
</section> : <>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
@ -211,6 +343,7 @@ export function GiftsPage() {
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
</>}
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>

View file

@ -276,6 +276,60 @@
.gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; }
.gift-source-tabs { display: flex; gap: 8px; }
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
.official-gift-tools .searchbox { width: 100%; }
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
.official-gift-categories button {
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
.official-gift-categories button span {
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
}
.official-gift-categories button.active span { color: var(--brand); }
.official-gift-list {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
background: #f6f9f8; scrollbar-gutter: stable;
}
.official-gift-option {
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
.official-gift-capabilities > span {
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
}
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
.official-gift-empty {
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
color: var(--muted); text-align: center; font-size: 12px;
}
.official-gift-selected {
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
}
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; }
.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; }
.official-gift-selected small { color: var(--muted); }
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
.gift-file-picker {
@ -321,6 +375,7 @@
.gift-fields-grid input,
.gift-reason-field input {
width: 100%;
min-width: 0;
height: 38px;
padding: 0 10px;
@ -440,6 +495,10 @@
.gift-file-action { display: none; }
.gift-fields-grid { grid-template-columns: 1fr; }
.gift-list-summary { width: 100%; margin-left: 0; }
.official-gift-tools { align-items: stretch; flex-direction: column; }
.official-gift-list { grid-template-columns: 1fr; max-height: 340px; }
.official-gift-selected { grid-template-columns: 82px minmax(0, 1fr); }
.official-gift-selected .gift-animation-shell { width: 72px; height: 72px; }
.collectible-modal-body { padding: 10px; }
.collectible-definition-head,
.collectible-section-head { align-items: flex-start; flex-direction: column; }

View file

@ -161,34 +161,58 @@ export type OutboxRow = {
};
export type StarGiftRow = {
GiftID: number;
RevisionID: number;
GiftID: string;
RevisionID: string;
Revision: number;
Title: string;
Stars: number;
ConvertStars: number;
Stars: string;
ConvertStars: string;
Enabled: boolean;
SortOrder: number;
DocumentID: number;
DocumentID: string;
SourceName: string;
SourceFormat: "tgs" | "lottie";
AnimationSHA: string;
AnimationSize: number;
AnimationSize: string;
Width: number;
Height: number;
FrameRate: number;
ReceivedCount: number;
ReceivedCount: string;
CreatedBy: string;
UpdatedAt: string;
};
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
export type OfficialStarGiftRow = {
source_gift_id: string;
title: string;
stars: string;
convert_stars: string;
upgrade_stars: string;
availability_total: number;
limited: boolean;
sold_out: boolean;
model_count: number;
pattern_count: number;
backdrop_count: number;
crafted_model_count: number;
can_upgrade: boolean;
can_craft: boolean;
document_id: string;
animation_validated: boolean;
};
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
export type StarGiftCollectibleAttributeRow = {
id: number;
id: string;
kind: "model" | "pattern" | "backdrop";
name: string;
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
rarity_permille: number;
crafted: boolean;
official_document_id: string;
sort_order: number;
source_name?: string;
source_format?: "tgs" | "lottie";
@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = {
export type StarGiftCollectiblePreview = {
found: boolean;
gift_id: number;
gift_id: string;
revision?: number;
upgrade_stars?: number;
upgrade_stars?: string;
supply_total?: number;
issued?: number;
slug_prefix?: string;

View file

@ -1,4 +1,4 @@
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server第一兼容目标Telegram Desktop
// Command telesrv 是基于 gotd/td 的 Telegram-like server第一兼容目标Telegram Desktop
package main
import (
@ -30,8 +30,10 @@ import (
botsapp "telesrv/internal/app/bots"
channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists"
communitiesapp "telesrv/internal/app/communities"
"telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs"
ephemeralapp "telesrv/internal/app/ephemeral"
filesapp "telesrv/internal/app/files"
groupcallsapp "telesrv/internal/app/groupcalls"
"telesrv/internal/app/help"
@ -56,6 +58,7 @@ import (
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
"telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook"
@ -361,6 +364,9 @@ func run(logger *zap.Logger) error {
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
@ -384,6 +390,7 @@ func run(logger *zap.Logger) error {
postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelBoostCache(channelBoostCache))
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
@ -474,6 +481,7 @@ func run(logger *zap.Logger) error {
adminService := adminapp.NewService(adminapp.Dependencies{
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)
}
@ -891,6 +953,8 @@ func run(logger *zap.Logger) error {
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
}, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err)
}

View file

@ -55,7 +55,7 @@ type appServer struct {
func main() {
cfg := config{}
flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address")
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button")
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telesrv menu button")
flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL")
flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>")
flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_ton_transactions;
DROP TABLE IF EXISTS public.channel_ton_balances;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.star_gift_purchase_forms;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.bot_api_webhooks;

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
DROP COLUMN IF EXISTS peer_filter;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
ADD COLUMN peer_filter jsonb NOT NULL DEFAULT '{}'::jsonb;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.ephemeral_abuse_reports;

View file

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

View file

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

View file

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

244
docs/configuration.en.md Normal file
View file

@ -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/<pack>/<pack>_<lang>_v<version>.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_<NAME>_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_<NAME>_BASE_URL` | URL string / empty | Optional provider endpoint override. Required by some compatible/self-hosted providers. |
| `TELESRV_AI_<NAME>_API_KEY` | secret string / provider fallback | Provider credential. For known providers it falls back to the process variables below. |
| `TELESRV_AI_<NAME>_MODEL` | string / empty | Provider model identifier. External providers generally require it. |
| `TELESRV_AI_<NAME>_MAX_OUTPUT_TOKENS` | int / `1024` | Requested output-token cap. |
| `TELESRV_AI_<NAME>_TEMPERATURE` | float / `0.2` | Sampling temperature. |
| `TELESRV_AI_<NAME>_OMIT_TEMPERATURE` | bool / `false` | Omits the temperature field for models/providers that reject it. |
| `TELESRV_AI_<NAME>_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_<NAME>_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://<AdvertiseIP>: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.

244
docs/configuration.zh-CN.md Normal file
View file

@ -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 bodyexact 是 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 bytePut 转移为真实 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/<pack>/<pack>_<lang>_v<version>.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 providerlocal 回显 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_<NAME>_KIND` | string / 由名称推导 | adapter 类型。内置值包括 `local``openai_responses``openai_chat``gemini``anthropic`;常用名称会自动映射。 |
| `TELESRV_AI_<NAME>_BASE_URL` | URL string / 空 | provider endpoint 覆盖;兼容接口或自托管 provider 通常需要。 |
| `TELESRV_AI_<NAME>_API_KEY` | secret string / provider fallback | provider 凭证;已知 provider 可回退到下述进程环境变量。 |
| `TELESRV_AI_<NAME>_MODEL` | string / 空 | provider model id外部 provider 通常必填。 |
| `TELESRV_AI_<NAME>_MAX_OUTPUT_TOKENS` | int / `1024` | 请求的输出 token 上限。 |
| `TELESRV_AI_<NAME>_TEMPERATURE` | float / `0.2` | 采样 temperature。 |
| `TELESRV_AI_<NAME>_OMIT_TEMPERATURE` | bool / `false` | 对拒绝 temperature 字段的模型/provider 不发送该字段。 |
| `TELESRV_AI_<NAME>_THINKING` | string / 空 | provider 特定 reasoning/thinking 模式,统一转小写,例如 `disabled`。 |
下列 fallback 只支持**进程环境变量**,因为 env 文件会拒绝不以 `TELESRV_` 开头的键:`OPENAI_API_KEY``GEMINI_API_KEY``ANTHROPIC_API_KEY`。显式 `TELESRV_AI_<NAME>_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://<AdvertiseIP>: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。

2
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

@ -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,7 +1086,8 @@ 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,
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille,
RarityPermille: uploads[i].RarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation,
}
}
@ -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,7 +1128,8 @@ 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,
"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),
}
@ -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
}

View file

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

Some files were not shown because too many files have changed in this diff Show more