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