Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -56,10 +56,10 @@ python .\cmd\bots\aiogramecho\echo.py `
## Webhook 模式
telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx
后推进 `update_id`。aiogram 可监听本机 HTTP由 Caddy/Nginx/Tunnel 提供公网 HTTPS
后推进 `update_id`。aiogram 可直接登记 HTTP/HTTPS 域名或 IP也可以由 Caddy/Nginx/Tunnel 提供公网 HTTPS
```powershell
$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook"
$env:TELESRV_BOT_WEBHOOK_URL = "http://192.0.2.25:8080/webhook"
$env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret"
python .\cmd\bots\aiogramecho\echo.py `
--mode webhook `
@ -69,8 +69,8 @@ python .\cmd\bots\aiogramecho\echo.py `
--drop-pending
```
公网 URL 必须是 HTTPS端口限 Telegram 标准的 443/80/88/8443;本机监听地址
可以是 HTTP因为 TLS 通常在反向代理终止`secret_token` 会由 telesrv 放入
Webhook URL 可使用任意合法 HTTP/HTTPS 域名或 IP 及 `1..65535` 端口;本机监听地址
也可以直接使用 HTTP`secret_token` 会由 telesrv 放入
`X-Telegram-Bot-Api-Secret-Token`aiogram 会自动校验。若希望进程退出时删除配置,
再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。

View file

@ -52,7 +52,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--webhook-url",
default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""),
help="Public HTTPS URL including the webhook path",
help="Public HTTP(S) URL including the webhook path",
)
parser.add_argument(
"--webhook-path",

View file

@ -1,4 +1,4 @@
# Bedolaga formatted-text demo
# Bedolaga formatted-text + Telegram Login demo
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
@ -14,6 +14,13 @@ telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legac
MarkdownV2用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
TDesktop` 链路。
`/richdemo` 进一步复刻 Bedolaga 的 rich menu调用 `sendRichMessage` 发送 HTML 与
Markdown `InputRichMessage`,携带 inline callback keyboard再通过
`editMessageText.rich_message` 编辑 HTML 菜单。HTML 样例覆盖 heading、divider、
bordered/striped table、`tg-time`、details、blockquote、code 与 footer。第一次请求
故意带远程 logo当前本地 blob backend 返回 `WEBPAGE_MEDIA_EMPTY`demo 按
Bedolaga 的既有策略自动去掉 logo 重试,正文与按钮不会降级成 classic menu。
## 安装
建议使用虚拟环境token 只通过环境变量传入:
@ -34,6 +41,7 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
```text
/start
/formatdemo
/richdemo
```
也可以不启动 polling直接向指定私聊发送三条格式测试消息
@ -46,5 +54,81 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
--marker BEDOLAGA-LOCAL-VERIFY
```
只主动验证 rich menuHTML + Markdown + 按钮 + 编辑 + logo fallback
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py `
--send-only `
--rich-only `
--send-chat-id 1780243200 `
--marker BEDOLAGA-RICH-VERIFY
```
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token也不会
把 token 写入文件。
## Telegram Login 全链路
同一个 demo 还提供 `/logindemo`,覆盖三个互相独立的公开契约:
1. Bot API `login_url` 按钮 → TDesktop/Android 的
`messages.requestUrlAuth/acceptUrlAuth` → legacy HMAC 回调;
2. telesrv 本地 `/telegram-login.js` → popup `postMessage` → JWKS 验签;
3. 服务端 Authorization Code + PKCE S256 → `/token` Basic Client Secret →
JWKS 验签和 `issuer/audience/nonce/subject` 复核。
先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择一次 bot 后,可逐条发送,
也可把下面三行作为一条多行消息粘贴,无需每次重新运行 `/setlogin` 或重选 bot
```text
add origin http://127.0.0.1:3000
add redirect http://127.0.0.1:3000/oauth/callback
enable
```
发送 `/done` 退出配置会话并查看最终摘要。`/cancel` 只退出,不回滚已经成功应用的命令。
`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret不要写进仓库。可用
`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。
使用 HTTP 域名/IP 时,在 telesrv 配置 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true`
demo 会接受任意合法 HTTP(S) issuer/public origin不再限制为 loopback。
把一次性 secret 和 Client ID 放入进程环境,再启动:
```powershell
$env:TELESRV_BOT_LOGIN_DEMO = "1"
$env:TELESRV_BOT_LOGIN_ISSUER = "http://127.0.0.1:2401"
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<numeric bot user id>"
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<one-time OIDC client secret>"
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://127.0.0.1:3000"
$env:TELESRV_BOT_LOGIN_LISTEN = "127.0.0.1:3000"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
```
向 bot 发送 `/logindemo`。第一颗按钮必须出现 Telegram 客户端原生授权确认框,批准
后网页显示 `login_url HMAC verified`;第二颗按钮打开测试页,可分别运行 JS SDK popup
和 Authorization Code + PKCE。页面只展示验签后的 claims不展示 access token 或
Client Secret。省略 `TELESRV_BOT_LOGIN_CLIENT_SECRET` 时仍可验证 JS popup但服务端
code flow 会明确禁用。
移动 Chrome 会在跳入 Telegram/DrKLO 前让 popup 把短期 browser token 交给原 RP
标签;父标签随后以精确登记 origin 轮询 `/auth/status`。因此外部 app round-trip
关闭 popup 或丢失 `window.opener` 时,原标签仍能完成 JWKS 验签。不要给
`/auth/status` 配 wildcard CORS也不要在 RP 中记录 browser token 或 ID token。
demo 的 flow/state/nonce 只保存在单进程内存中,带 10 分钟过期和 256 条上限,专用于
本地与受控测试部署端到端验证,不是生产 relying-party 实现。官方 iOS/Android SDK
目前把 `https://oauth.telegram.org` 写死;验证自建 issuer 时需使用项目记录的最小
base-URL patch 或等价测试构建,不能把官方生产 SDK 未修改的结果误判为自建服务结果。
测试命令:
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\test_demo.py -v
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\test_login_demo.py -v
```

View file

@ -21,8 +21,22 @@ 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.exceptions import TelegramBadRequest
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
InputRichMessage,
LoginUrl,
Message,
)
from login_demo import (
LoginDemoConfig,
LoginDemoServer,
normalize_web_base,
parse_listen,
)
LOG = logging.getLogger("bedolagaformat")
@ -30,6 +44,10 @@ MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])")
def env_flag(name: str) -> bool:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class FormatSample:
name: str
@ -101,19 +119,77 @@ def parse_args() -> argparse.Namespace:
help="Send the complete suite proactively before polling",
)
parser.add_argument("--send-only", action="store_true")
parser.add_argument(
"--rich-menu",
action="store_true",
help="also send and edit Bedolaga-style rich HTML/Markdown menus",
)
parser.add_argument(
"--rich-only",
action="store_true",
help="with --send-only, send only the rich menu suite",
)
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")
parser.add_argument(
"--login-demo",
action="store_true",
default=env_flag("TELESRV_BOT_LOGIN_DEMO"),
help="serve and send the Bedolaga Telegram Login/OIDC demo",
)
parser.add_argument(
"--login-issuer",
default=os.getenv("TELESRV_BOT_LOGIN_ISSUER", "http://127.0.0.1:2401"),
)
parser.add_argument(
"--login-client-id",
default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_ID", ""),
)
parser.add_argument(
"--login-client-secret",
default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_SECRET", ""),
help="confidential OIDC secret; never printed (optional for JS-only demo)",
)
parser.add_argument(
"--login-public-url",
default=os.getenv("TELESRV_BOT_LOGIN_PUBLIC_URL", "http://127.0.0.1:3000"),
help="registered origin where this demo is reachable",
)
parser.add_argument(
"--login-listen",
default=os.getenv("TELESRV_BOT_LOGIN_LISTEN", "127.0.0.1:3000"),
)
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 args.rich_only and (not args.send_only or args.send_chat_id is None):
parser.error("--rich-only requires --send-only and --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")
args.login_config = None
if args.login_demo:
if not re.fullmatch(r"[0-9]{1,64}", args.login_client_id):
parser.error("--login-demo requires a numeric --login-client-id")
try:
issuer = normalize_web_base(args.login_issuer, name="login issuer")
public_url = normalize_web_base(args.login_public_url, name="login public URL")
listen_host, listen_port = parse_listen(args.login_listen)
except ValueError as exc:
parser.error(str(exc))
args.login_config = LoginDemoConfig(
issuer=issuer,
client_id=args.login_client_id,
client_secret=args.login_client_secret,
public_url=public_url,
listen_host=listen_host,
listen_port=listen_port,
)
return args
@ -151,7 +227,130 @@ async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
return message_ids
def build_dispatcher(marker: str) -> Dispatcher:
def rich_menu_html(marker: str, *, include_logo: bool) -> str:
"""Build the rich HTML families used by Bedolaga's main menu."""
logo = '<img src="https://example.com/bedolaga-logo.png">' if include_logo else ""
return (
f"{logo}<h4>{marker} Admin</h4>"
"<h6>Subscription overview</h6><hr>"
"<table bordered striped>"
"<tr><th>Status</th><td align=\"right\">Active</td></tr>"
"<tr><th>Updated</th><td align=\"right\">"
'<tg-time unix="1700000000" format="R">now</tg-time>'
"</td></tr></table>"
"<details open><summary>Diagnostics</summary>"
"<blockquote><code>rich menu online</code></blockquote></details>"
"<footer>Choose an option</footer>"
)
def rich_menu_markdown(marker: str) -> str:
return (
f"#### {marker} Markdown menu\n\n"
"**Subscription:** Active\n\n"
"> Rich Markdown transport is online.\n\n"
"`callback keyboard preserved`"
)
def rich_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="Balance", callback_data="menu:balance"),
InlineKeyboardButton(text="Buy", callback_data="menu:buy"),
],
[InlineKeyboardButton(text="Info", callback_data="menu:info")],
]
)
def login_demo_keyboard(config: LoginDemoConfig) -> InlineKeyboardMarkup:
"""Exercise both Telegram's login_url button and a plain OIDC page URL."""
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Log in with Telegram",
login_url=LoginUrl(
url=config.public_url + "/",
forward_text="Bedolaga Login",
request_write_access=True,
),
)
],
[InlineKeyboardButton(text="Open OIDC test page", url=config.public_url + "/")],
]
)
async def send_login_demo(bot: Bot, chat_id: int, marker: str, config: LoginDemoConfig) -> int:
message = await bot.send_message(
chat_id=chat_id,
text=(
f"<b>{marker} Telegram Login</b>\n"
"The first button validates Bot API <code>login_url</code>; "
"the second page validates the local JS SDK and OIDC + PKCE."
),
reply_markup=login_demo_keyboard(config),
)
LOG.info("sent Telegram Login demo chat_id=%s message_id=%s", chat_id, message.message_id)
return message.message_id
def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool:
message = str(exc).lower()
return "webpage_" in message or "media_empty" in message or "media_invalid" in message
async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
"""Exercise Bedolaga's send, no-logo retry, keyboard and rich edit path."""
markup = rich_menu_keyboard()
try:
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=True),
skip_entity_detection=True,
),
reply_markup=markup,
)
except TelegramBadRequest as exc:
if not is_rich_media_retry_error(exc):
raise
LOG.info("rich logo fetch rejected; retrying the menu without logo")
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
markdown_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
markdown=rich_menu_markdown(marker),
skip_entity_detection=True,
),
reply_markup=markup,
)
await bot.edit_message_text(
chat_id=chat_id,
message_id=html_message.message_id,
rich_message=InputRichMessage(
html=rich_menu_html(f"{marker} EDITED", include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
ids = [html_message.message_id, markdown_message.message_id]
LOG.info("sent rich menu suite chat_id=%s message_ids=%s", chat_id, ids)
return ids
def build_dispatcher(marker: str, login_config: LoginDemoConfig | None = None) -> Dispatcher:
router = Router(name="telesrv-bedolaga-format")
@router.message(CommandStart())
@ -173,6 +372,32 @@ def build_dispatcher(marker: str) -> Dispatcher:
ids,
)
@router.message(Command("richdemo"))
async def rich_demo(message: Message) -> None:
ids = await send_rich_suite(message.bot, message.chat.id, marker)
LOG.info(
"handled /richdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
message.chat.id,
message.message_id,
ids,
)
@router.message(Command("logindemo"))
async def login_demo(message: Message) -> None:
if login_config is None:
await message.answer(
"<b>Telegram Login demo is disabled.</b> Start this program with "
"<code>--login-demo</code>."
)
return
message_id = await send_login_demo(message.bot, message.chat.id, marker, login_config)
LOG.info(
"handled /logindemo chat_id=%s incoming_message_id=%s sent_message_id=%s",
message.chat.id,
message.message_id,
message_id,
)
dispatcher = Dispatcher()
dispatcher.include_router(router)
return dispatcher
@ -180,7 +405,10 @@ def build_dispatcher(marker: str) -> Dispatcher:
async def run(args: argparse.Namespace) -> None:
bot = create_bot(args.token, args.base_url)
login_server = LoginDemoServer(args.login_config, args.token) if args.login_config else None
try:
if login_server is not None:
await login_server.start()
me = await bot.get_me()
LOG.info(
"authenticated bot_id=%s username=@%s bot_api=%s marker=%s",
@ -190,13 +418,21 @@ async def run(args: argparse.Namespace) -> None:
args.marker,
)
if args.send_chat_id is not None:
await send_format_suite(bot, args.send_chat_id, args.marker)
if not args.rich_only:
await send_format_suite(bot, args.send_chat_id, args.marker)
if args.rich_menu or args.rich_only:
await send_rich_suite(bot, args.send_chat_id, args.marker)
if args.login_config is not None:
await send_login_demo(bot, args.send_chat_id, args.marker, args.login_config)
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)
dispatcher = build_dispatcher(args.marker, args.login_config)
LOG.info(
"polling started; send /start, /formatdemo, /richdemo or /logindemo to @%s",
me.username or me.id,
)
await dispatcher.start_polling(
bot,
allowed_updates=["message"],
@ -204,6 +440,8 @@ async def run(args: argparse.Namespace) -> None:
close_bot_session=False,
)
finally:
if login_server is not None:
await login_server.close()
await bot.session.close()

View file

@ -0,0 +1,456 @@
"""Local Bedolaga Telegram Login/OIDC relying-party demo.
This is deliberately a relying party, not a shortcut into telesrv internals. It
validates the three public contracts used by a Bedolaga-style bot:
* Bot API ``login_url`` legacy HMAC callbacks;
* the self-hosted Telegram Login JavaScript SDK ``post_message`` response; and
* confidential authorization-code + PKCE followed by JWKS ID-token validation.
The demo keeps its short-lived browser flows in memory. It is intended for
local/end-to-end verification only and must not be used as a production login
backend.
"""
from __future__ import annotations
import asyncio
import base64
from dataclasses import dataclass
import hashlib
import hmac
import html
import json
import logging
import secrets
import time
from typing import Any
from urllib.parse import urlencode, urlsplit
from aiohttp import BasicAuth, ClientSession, ClientTimeout, web
import jwt
LOG = logging.getLogger("bedolagaformat.login")
FLOW_TTL_SECONDS = 10 * 60
MAX_PENDING_FLOWS = 256
LEGACY_AUTH_MAX_AGE_SECONDS = 15 * 60
OIDC_ALGORITHMS = ("RS256", "ES256", "EdDSA", "ES256K")
@dataclass(frozen=True)
class LoginDemoConfig:
issuer: str
client_id: str
client_secret: str
public_url: str
listen_host: str
listen_port: int
@property
def redirect_uri(self) -> str:
return self.public_url + "/oauth/callback"
@property
def origin(self) -> str:
parsed = urlsplit(self.public_url)
return f"{parsed.scheme}://{parsed.netloc}"
@property
def code_flow_enabled(self) -> bool:
return bool(self.client_secret)
@dataclass(frozen=True)
class PendingFlow:
nonce: str
expires_at: float
code_verifier: str = ""
def normalize_web_base(value: str, *, name: str) -> str:
raw = value.strip().rstrip("/")
parsed = urlsplit(raw)
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
):
raise ValueError(f"{name} must be an absolute origin without path, query, or fragment")
return f"{parsed.scheme}://{parsed.netloc}"
def parse_listen(value: str) -> tuple[str, int]:
parsed = urlsplit("//" + value.strip())
try:
port = parsed.port
except ValueError as exc:
raise ValueError("login demo listen port is invalid") from exc
if not parsed.hostname or port is None or not 1 <= port <= 65535:
raise ValueError("login demo listen address must be host:port")
return parsed.hostname, port
def base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def generate_pkce() -> tuple[str, str]:
verifier = base64url(secrets.token_bytes(32))
challenge = base64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def verify_legacy_login_query(
query: dict[str, str], bot_token: str, *, now: int | None = None
) -> dict[str, str]:
"""Verify Telegram's legacy login_url data-check string.
Only the documented signed identity fields participate. Query parameters
already present on the relying-party URL are intentionally excluded.
"""
signed_names = (
"auth_date",
"first_name",
"id",
"last_name",
"photo_url",
"username",
)
supplied_hash = query.get("hash", "")
if len(supplied_hash) != 64:
raise ValueError("missing legacy login signature")
values = {name: query[name] for name in signed_names if name in query}
if not all(values.get(name) for name in ("auth_date", "first_name", "id")):
raise ValueError("incomplete legacy login payload")
try:
auth_date = int(values["auth_date"])
user_id = int(values["id"])
except ValueError as exc:
raise ValueError("invalid legacy login payload") from exc
current = int(time.time()) if now is None else now
if user_id <= 0 or auth_date > current + 30 or current - auth_date > LEGACY_AUTH_MAX_AGE_SECONDS:
raise ValueError("expired legacy login payload")
data_check = "\n".join(f"{name}={values[name]}" for name in sorted(values))
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
actual = hmac.new(secret_key, data_check.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(actual, supplied_hash.lower()):
raise ValueError("invalid legacy login signature")
return values
def _safe_claims(claims: dict[str, Any]) -> dict[str, Any]:
allowed = (
"iss",
"aud",
"sub",
"iat",
"exp",
"nonce",
"id",
"name",
"given_name",
"family_name",
"preferred_username",
"picture",
"phone_number",
"phone_number_verified",
)
return {key: claims[key] for key in allowed if key in claims}
class LoginDemoServer:
def __init__(self, config: LoginDemoConfig, bot_token: str) -> None:
self.config = config
self.bot_token = bot_token
self._flows: dict[str, PendingFlow] = {}
self._flow_lock = asyncio.Lock()
self._http: ClientSession | None = None
self._runner: web.AppRunner | None = None
async def start(self) -> None:
timeout = ClientTimeout(total=10)
self._http = ClientSession(timeout=timeout)
app = web.Application(client_max_size=64 * 1024)
app.add_routes(
[
web.get("/", self.root),
web.get("/login/code", self.start_code_flow),
web.get("/oauth/callback", self.code_callback),
web.post("/verify-popup", self.verify_popup),
web.get("/healthz", self.health),
]
)
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, self.config.listen_host, self.config.listen_port)
await site.start()
LOG.info(
"Telegram Login demo listening at %s (issuer=%s client_id=%s)",
self.config.public_url,
self.config.issuer,
self.config.client_id,
)
async def close(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
if self._http is not None:
await self._http.close()
self._http = None
async def _put_flow(self, flow: PendingFlow) -> str:
flow_id = secrets.token_urlsafe(24)
now = time.time()
async with self._flow_lock:
self._flows = {
key: value for key, value in self._flows.items() if value.expires_at > now
}
if len(self._flows) >= MAX_PENDING_FLOWS:
oldest = min(self._flows, key=lambda key: self._flows[key].expires_at)
del self._flows[oldest]
self._flows[flow_id] = flow
return flow_id
async def _take_flow(self, flow_id: str, *, consume: bool) -> PendingFlow:
async with self._flow_lock:
flow = self._flows.get(flow_id)
if flow is None or flow.expires_at <= time.time():
self._flows.pop(flow_id, None)
raise ValueError("login flow is invalid or expired")
if consume:
del self._flows[flow_id]
return flow
@staticmethod
def _headers(response: web.StreamResponse) -> None:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
async def health(self, _: web.Request) -> web.Response:
response = web.json_response({"status": "ok"})
self._headers(response)
return response
async def root(self, request: web.Request) -> web.Response:
legacy_result = ""
if "hash" in request.query:
try:
signed_names = {
"auth_date", "first_name", "id", "last_name", "photo_url", "username", "hash"
}
if any(len(request.query.getall(key, [])) != 1 for key in signed_names if key in request.query):
raise ValueError("duplicate legacy login field")
query = {key: request.query[key] for key in request.query}
identity = verify_legacy_login_query(query, self.bot_token)
legacy_result = (
"<p class=\"ok\">login_url HMAC verified for user "
+ html.escape(identity["id"])
+ ".</p>"
)
except ValueError:
legacy_result = '<p class="error">login_url HMAC verification failed.</p>'
nonce = secrets.token_urlsafe(24)
flow_id = await self._put_flow(PendingFlow(nonce=nonce, expires_at=time.time() + FLOW_TTL_SECONDS))
sdk_url = self.config.issuer + "/js/telegram-login.js"
csp_nonce = secrets.token_urlsafe(18)
code_link = '<a class="button" href="/login/code">Authorization Code + PKCE</a>'
if not self.config.code_flow_enabled:
code_link = '<span class="disabled">Code flow disabled: configure client secret.</span>'
script_config = json.dumps(
{"clientID": self.config.client_id, "flowID": flow_id, "nonce": nonce},
separators=(",", ":"),
).replace("<", "\\u003c")
body = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Bedolaga Telegram Login Demo</title>
<style>body{{font:16px/1.5 system-ui,sans-serif;max-width:720px;margin:8vh auto;padding:24px;background:#17212b;color:#fff}}.card{{background:#202b36;padding:28px;border-radius:18px}}button,.button{{display:inline-block;border:0;border-radius:10px;padding:12px 18px;margin:8px 8px 8px 0;background:#2aabee;color:#fff;text-decoration:none;font-weight:700;cursor:pointer}}.disabled{{display:block;color:#a9b5c1;margin:12px 0}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;background:#111b24;padding:14px;border-radius:8px}}.ok{{color:#73d18a}}.error{{color:#ff8d8d}}</style></head>
<body><main class="card"><h1>Bedolaga Telegram Login Demo</h1>{legacy_result}
<p>This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.</p>
<button id="popup">JavaScript SDK popup</button>{code_link}<pre id="result">Ready.</pre></main>
<script src="{html.escape(sdk_url, quote=True)}"></script>
<script nonce="{csp_nonce}">const cfg={script_config},out=document.getElementById('result');document.getElementById('popup').addEventListener('click',()=>{{out.textContent='Waiting for Telegram approval…';Telegram.Login.auth({{client_id:cfg.clientID,request_access:['phone','write'],nonce:cfg.nonce}},async result=>{{if(result.error){{out.textContent='Login failed: '+result.error;return}}try{{const response=await fetch('/verify-popup',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{flow_id:cfg.flowID,id_token:result.id_token,in_app:Boolean(window.TelegramWebviewProxy)}})}}),verified=await response.json();if(!response.ok)throw new Error(verified.error||'verification_failed');out.textContent=JSON.stringify(verified.claims,null,2)}}catch(error){{out.textContent='Verification failed: '+error.message}}}})}});</script></body></html>"""
response = web.Response(text=body, content_type="text/html")
self._headers(response)
response.headers["Content-Security-Policy"] = (
"default-src 'none'; style-src 'unsafe-inline'; "
f"script-src '{csp_nonce}' {self.config.issuer}; connect-src 'self' {self.config.issuer}; "
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
)
# CSP nonce source expressions include the nonce- prefix.
response.headers["Content-Security-Policy"] = response.headers["Content-Security-Policy"].replace(
f"'{csp_nonce}'", f"'nonce-{csp_nonce}'"
)
return response
async def start_code_flow(self, _: web.Request) -> web.Response:
if not self.config.code_flow_enabled:
raise web.HTTPNotFound()
verifier, challenge = generate_pkce()
nonce = secrets.token_urlsafe(24)
state = await self._put_flow(
PendingFlow(nonce=nonce, code_verifier=verifier, expires_at=time.time() + FLOW_TTL_SECONDS)
)
query = urlencode(
{
"client_id": self.config.client_id,
"redirect_uri": self.config.redirect_uri,
"response_type": "code",
"scope": "openid profile phone telegram:bot_access",
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
)
response = web.HTTPFound(self.config.issuer + "/auth?" + query)
self._headers(response)
raise response
async def code_callback(self, request: web.Request) -> web.Response:
state = request.query.get("state", "")
try:
flow = await self._take_flow(state, consume=True)
except ValueError:
return self._result_page("Authorization failed", {"error": "invalid_or_expired_state"}, ok=False)
if request.query.get("error"):
return self._result_page("Authorization declined", {"error": request.query["error"]}, ok=False)
code = request.query.get("code", "")
if not code or len(code) > 2048:
return self._result_page("Authorization failed", {"error": "missing_code"}, ok=False)
try:
token = await self._exchange_code(code, flow.code_verifier)
claims = await self.verify_id_token(token, flow.nonce)
except Exception as exc: # noqa: BLE001 - convert all protocol failures to a safe demo page
LOG.warning("OIDC code flow verification failed: %s", type(exc).__name__)
return self._result_page("Authorization failed", {"error": "token_verification_failed"}, ok=False)
return self._result_page("Authorization complete", _safe_claims(claims), ok=True)
async def verify_popup(self, request: web.Request) -> web.Response:
origin = request.headers.get("Origin")
if origin and origin != self.config.origin:
return web.json_response({"error": "invalid_origin"}, status=403)
try:
payload = await request.json()
flow_id = str(payload.get("flow_id", ""))
id_token = str(payload.get("id_token", ""))
in_app = payload.get("in_app", False)
if (
not flow_id
or len(flow_id) > 128
or not id_token
or len(id_token) > 16384
or not isinstance(in_app, bool)
):
raise ValueError("invalid popup response")
flow = await self._take_flow(flow_id, consume=True)
# Telegram's official Mini App /inapp contract has exactly four
# request parameters and does not carry the JS API nonce. Popup
# flows still require it; Mini App tokens must omit it.
claims = await self.verify_id_token(id_token, "" if in_app else flow.nonce)
except Exception as exc: # noqa: BLE001 - safe public validation failure
LOG.info("OIDC popup verification rejected: %s", type(exc).__name__)
response = web.json_response({"error": "token_verification_failed"}, status=400)
self._headers(response)
return response
response = web.json_response({"claims": _safe_claims(claims)})
self._headers(response)
return response
async def _discovery(self) -> dict[str, Any]:
if self._http is None:
raise RuntimeError("login demo server is not started")
async with self._http.get(self.config.issuer + "/.well-known/openid-configuration") as response:
response.raise_for_status()
document = await response.json()
if document.get("issuer") != self.config.issuer:
raise ValueError("OIDC issuer mismatch")
issuer_origin = urlsplit(self.config.issuer).netloc
for name in ("token_endpoint", "jwks_uri"):
endpoint = urlsplit(str(document.get(name, "")))
if endpoint.scheme != urlsplit(self.config.issuer).scheme or endpoint.netloc != issuer_origin:
raise ValueError(f"OIDC {name} must share the configured issuer origin")
return document
async def _exchange_code(self, code: str, verifier: str) -> str:
if self._http is None:
raise RuntimeError("login demo server is not started")
discovery = await self._discovery()
form = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.config.redirect_uri,
"code_verifier": verifier,
}
async with self._http.post(
discovery["token_endpoint"],
data=form,
auth=BasicAuth(self.config.client_id, self.config.client_secret),
) as response:
document = await response.json()
if response.status != 200:
raise ValueError("OIDC token endpoint rejected the grant")
token = document.get("id_token")
if not isinstance(token, str) or not token:
raise ValueError("OIDC token response omitted id_token")
return token
async def verify_id_token(self, token: str, nonce: str) -> dict[str, Any]:
if self._http is None:
raise RuntimeError("login demo server is not started")
discovery = await self._discovery()
header = jwt.get_unverified_header(token)
kid, algorithm = header.get("kid"), header.get("alg")
if not isinstance(kid, str) or algorithm not in OIDC_ALGORITHMS:
raise ValueError("unsupported ID token header")
async with self._http.get(discovery["jwks_uri"]) as response:
response.raise_for_status()
document = await response.json()
raw_key = next((key for key in document.get("keys", []) if key.get("kid") == kid), None)
if raw_key is None:
raise ValueError("ID token signing key not found")
public_key = jwt.PyJWK.from_dict(raw_key, algorithm=algorithm).key
required_claims = ["iss", "aud", "sub", "iat", "exp"]
if nonce:
required_claims.append("nonce")
claims = jwt.decode(
token,
public_key,
algorithms=[algorithm],
audience=self.config.client_id,
issuer=self.config.issuer,
options={"require": required_claims},
)
if nonce and not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
raise ValueError("ID token nonce mismatch")
if not nonce and claims.get("nonce") not in (None, ""):
raise ValueError("unexpected ID token nonce")
if str(claims.get("sub", "")) != str(claims.get("id", "")):
raise ValueError("ID token subject mismatch")
return claims
def _result_page(self, title: str, payload: dict[str, Any], *, ok: bool) -> web.Response:
css_class = "ok" if ok else "error"
body = (
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>'
+ html.escape(title)
+ "</title></head><body><h1 class=\""
+ css_class
+ "\">"
+ html.escape(title)
+ "</h1><pre>"
+ html.escape(json.dumps(payload, indent=2, ensure_ascii=False))
+ '</pre><p><a href="/">Run another flow</a></p></body></html>'
)
response = web.Response(text=body, content_type="text/html")
self._headers(response)
response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
return response

View file

@ -1 +1,2 @@
aiogram==3.30.0
PyJWT[crypto]==2.10.1

View file

@ -5,9 +5,13 @@ import unittest
from unittest.mock import AsyncMock
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendRichMessage
from aiogram.types import InputRichMessage
MODULE_PATH = Path(__file__).with_name("demo.py")
sys.path.insert(0, str(MODULE_PATH.parent))
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)
@ -47,6 +51,83 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
def test_rich_menu_covers_bedolaga_html_and_keyboard(self) -> None:
html = demo.rich_menu_html("BEDOLAGA123", include_logo=False)
self.assertIn("<h4>BEDOLAGA123 Admin</h4>", html)
self.assertIn("<table bordered striped>", html)
self.assertIn("<tg-time", html)
self.assertIn("<details open>", html)
self.assertIn("<footer>", html)
markup = demo.rich_menu_keyboard()
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "menu:balance")
self.assertEqual(markup.inline_keyboard[1][0].callback_data, "menu:info")
async def test_rich_suite_retries_without_logo_and_edits(self) -> None:
bot = AsyncMock()
media_error = TelegramBadRequest(
method=SendRichMessage(
chat_id=1780243200,
rich_message=InputRichMessage(html="<p>fixture</p>"),
),
message="WEBPAGE_MEDIA_EMPTY",
)
bot.send_rich_message.side_effect = [
media_error,
SentMessage(21),
SentMessage(22),
]
bot.edit_message_text.return_value = SentMessage(21)
ids = await demo.send_rich_suite(bot, 1780243200, "BEDOLAGA123")
self.assertEqual(ids, [21, 22])
sends = bot.send_rich_message.await_args_list
self.assertEqual(len(sends), 3)
self.assertIn("<img", sends[0].kwargs["rich_message"].html)
self.assertNotIn("<img", sends[1].kwargs["rich_message"].html)
self.assertIsNotNone(sends[2].kwargs["rich_message"].markdown)
edit = bot.edit_message_text.await_args
self.assertEqual(edit.kwargs["message_id"], 21)
self.assertIn("EDITED", edit.kwargs["rich_message"].html)
def test_login_demo_keyboard_has_login_url_and_plain_oidc_link(self) -> None:
config = demo.LoginDemoConfig(
issuer="https://oauth.example",
client_id="9001",
client_secret="secret",
public_url="https://rp.example",
listen_host="127.0.0.1",
listen_port=3000,
)
markup = demo.login_demo_keyboard(config)
login = markup.inline_keyboard[0][0].login_url
self.assertIsNotNone(login)
self.assertEqual(login.url, "https://rp.example/")
self.assertTrue(login.request_write_access)
self.assertEqual(markup.inline_keyboard[1][0].url, "https://rp.example/")
async def test_send_login_demo_preserves_default_html_and_keyboard(self) -> None:
config = demo.LoginDemoConfig(
issuer="https://oauth.example",
client_id="9001",
client_secret="secret",
public_url="https://rp.example",
listen_host="127.0.0.1",
listen_port=3000,
)
bot = AsyncMock()
bot.send_message.return_value = SentMessage(31)
message_id = await demo.send_login_demo(bot, 1780243200, "BEDOLAGA123", config)
self.assertEqual(message_id, 31)
call = bot.send_message.await_args
self.assertNotIn("parse_mode", call.kwargs)
self.assertEqual(
call.kwargs["reply_markup"].inline_keyboard[0][0].login_url.url,
"https://rp.example/",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,160 @@
import asyncio
import hashlib
import hmac
from pathlib import Path
import sys
import time
import unittest
from aiohttp import ClientSession, web
from aiohttp.test_utils import TestServer
from cryptography.hazmat.primitives.asymmetric import rsa
import jwt
sys.path.insert(0, str(Path(__file__).parent))
import login_demo as demo # noqa: E402
class LoginDemoHelpersTest(unittest.TestCase):
def test_legacy_login_hmac_and_freshness(self) -> None:
now = 1_800_000_000
token = "9001:bot-secret"
values = {
"auth_date": str(now - 10),
"first_name": "Alice",
"id": "42",
"username": "alice",
}
data_check = "\n".join(f"{key}={values[key]}" for key in sorted(values))
key = hashlib.sha256(token.encode()).digest()
values["hash"] = hmac.new(key, data_check.encode(), hashlib.sha256).hexdigest()
values["untrusted_existing_query"] = "not-signed"
verified = demo.verify_legacy_login_query(values, token, now=now)
self.assertEqual(verified["id"], "42")
self.assertNotIn("untrusted_existing_query", verified)
with self.assertRaisesRegex(ValueError, "signature"):
demo.verify_legacy_login_query({**values, "id": "43"}, token, now=now)
with self.assertRaisesRegex(ValueError, "expired"):
demo.verify_legacy_login_query(values, token, now=now + 3600)
def test_web_origins_and_listen_are_strict(self) -> None:
self.assertEqual(
demo.normalize_web_base("https://rp.example/", name="RP"),
"https://rp.example",
)
self.assertEqual(
demo.normalize_web_base("http://127.0.0.1:3000", name="RP"),
"http://127.0.0.1:3000",
)
self.assertEqual(
demo.normalize_web_base("http://192.0.2.25:3000", name="RP"),
"http://192.0.2.25:3000",
)
self.assertEqual(
demo.normalize_web_base("http://rp.example:18080", name="RP"),
"http://rp.example:18080",
)
with self.assertRaises(ValueError):
demo.normalize_web_base("https://rp.example/callback", name="RP")
self.assertEqual(demo.parse_listen("127.0.0.1:3000"), ("127.0.0.1", 3000))
class LoginDemoTokenTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
raw_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(self.private_key.public_key(), as_dict=True)
raw_jwk.update({"kid": "demo-rs256", "alg": "RS256", "use": "sig"})
self.jwk = raw_jwk
self.issuer = ""
async def discovery(_: web.Request) -> web.Response:
return web.json_response(
{
"issuer": self.issuer,
"token_endpoint": self.issuer + "/token",
"jwks_uri": self.issuer + "/jwks",
}
)
async def jwks(_: web.Request) -> web.Response:
return web.json_response({"keys": [self.jwk]})
app = web.Application()
app.add_routes([web.get("/.well-known/openid-configuration", discovery), web.get("/jwks", jwks)])
self.http_server = TestServer(app)
await self.http_server.start_server()
self.issuer = str(self.http_server.make_url("")).rstrip("/")
config = demo.LoginDemoConfig(
issuer=self.issuer,
client_id="9001",
client_secret="secret",
public_url="http://127.0.0.1:3000",
listen_host="127.0.0.1",
listen_port=3000,
)
self.demo = demo.LoginDemoServer(config, "9001:bot-secret")
self.demo._http = ClientSession()
async def asyncTearDown(self) -> None:
await self.demo._http.close()
await self.http_server.close()
async def test_id_token_requires_signature_issuer_audience_nonce_and_subject(self) -> None:
now = int(time.time())
claims = {
"iss": self.issuer,
"aud": "9001",
"sub": "42",
"id": 42,
"iat": now,
"exp": now + 300,
"nonce": "expected-nonce",
"name": "Alice",
}
token = jwt.encode(
claims,
self.private_key,
algorithm="RS256",
headers={"kid": "demo-rs256"},
)
verified = await self.demo.verify_id_token(token, "expected-nonce")
self.assertEqual(verified["sub"], "42")
with self.assertRaisesRegex(ValueError, "nonce"):
await self.demo.verify_id_token(token, "wrong-nonce")
with self.assertRaisesRegex(ValueError, "nonce"):
await self.demo.verify_id_token(token, "")
in_app_claims = dict(claims)
in_app_claims.pop("nonce")
in_app_token = jwt.encode(
in_app_claims,
self.private_key,
algorithm="RS256",
headers={"kid": "demo-rs256"},
)
verified_in_app = await self.demo.verify_id_token(in_app_token, "")
self.assertEqual(verified_in_app["sub"], "42")
async def test_pending_flow_is_one_time_and_expiring(self) -> None:
flow_id = await self.demo._put_flow(
demo.PendingFlow(nonce="n", expires_at=time.time() + 10)
)
flow = await self.demo._take_flow(flow_id, consume=True)
self.assertEqual(flow.nonce, "n")
with self.assertRaises(ValueError):
await self.demo._take_flow(flow_id, consume=True)
expired = await self.demo._put_flow(
demo.PendingFlow(nonce="old", expires_at=time.time() - 1)
)
with self.assertRaises(ValueError):
await self.demo._take_flow(expired, consume=True)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,391 @@
// Command telegramloginkeygen initializes and rotates telesrv Telegram Login
// key files without ever writing secret material to stdout.
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
telegramlogin "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
)
const maxManifestBytes = 1 << 20
const signingRetirementMargin = 10 * time.Minute
type signingManifest struct {
Version int `json:"version"`
Keys []signingManifestKey `json:"keys"`
}
type signingManifestKey struct {
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
KeyID string `json:"kid"`
PrivateKeyFile string `json:"private_key_file"`
Active bool `json:"active"`
PublishUntil string `json:"publish_until,omitempty"`
}
type codeManifest struct {
Version int `json:"version"`
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
type options struct {
mode string
dir string
algorithm domain.TelegramLoginSigningAlgorithm
publishFor time.Duration
idTokenTTL time.Duration
now func() time.Time
}
func main() {
mode := flag.String("mode", "init", "init, rotate-signing, or rotate-code")
dir := flag.String("dir", "data/telegram-login", "key directory")
algorithm := flag.String("algorithm", "RS256", "signing algorithm to rotate: RS256, ES256, or EdDSA")
publishFor := flag.Duration("publish-for", 2*time.Hour, "how long the retiring public key remains in JWKS")
idTokenTTL := flag.Duration("id-token-ttl", time.Hour, "configured TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL")
flag.Parse()
if flag.NArg() != 0 {
fatal(errors.New("positional arguments are not accepted"))
}
opts := options{
mode: strings.ToLower(strings.TrimSpace(*mode)), dir: strings.TrimSpace(*dir),
algorithm: domain.TelegramLoginSigningAlgorithm(strings.ToUpper(strings.TrimSpace(*algorithm))),
publishFor: *publishFor, idTokenTTL: *idTokenTTL, now: time.Now,
}
if err := run(opts); err != nil {
fatal(err)
}
fmt.Printf("Telegram Login key operation %s completed in %s; restart all instances to load one consistent key ring.\n", opts.mode, opts.dir)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "telegramloginkeygen:", err)
os.Exit(1)
}
func run(opts options) error {
if opts.now == nil {
opts.now = time.Now
}
if opts.dir == "" {
return errors.New("key directory is required")
}
absDir, err := filepath.Abs(opts.dir)
if err != nil {
return fmt.Errorf("resolve key directory: %w", err)
}
if err := os.MkdirAll(absDir, 0o700); err != nil {
return fmt.Errorf("create key directory: %w", err)
}
if err := os.Chmod(absDir, 0o700); err != nil {
return fmt.Errorf("restrict key directory: %w", err)
}
lockPath := filepath.Join(absDir, ".keygen.lock")
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("acquire key operation lock: %w", err)
}
_ = lock.Close()
defer func() { _ = os.Remove(lockPath) }()
switch opts.mode {
case "init":
return initialize(absDir, opts.now().UTC())
case "rotate-signing":
if opts.idTokenTTL < time.Minute || opts.idTokenTTL > 24*time.Hour {
return errors.New("id-token-ttl must match the configured 1m..24h ID-token TTL")
}
if opts.publishFor < opts.idTokenTTL+signingRetirementMargin || opts.publishFor > 90*24*time.Hour {
return fmt.Errorf("publish-for must be at least id-token-ttl plus %s and at most 2160h", signingRetirementMargin)
}
return rotateSigning(absDir, opts.algorithm, opts.publishFor, opts.now().UTC())
case "rotate-code":
return rotateCode(absDir, opts.now().UTC())
default:
return errors.New("mode must be init, rotate-signing, or rotate-code")
}
}
func initialize(dir string, now time.Time) error {
for _, name := range []string{"signing-keys.json", "code-keys.json", "client-secret-pepper"} {
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
return fmt.Errorf("refusing to overwrite existing %s", name)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect %s: %w", name, err)
}
}
manifest := signingManifest{Version: 1}
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
domain.TelegramLoginSigningRS256,
domain.TelegramLoginSigningES256,
domain.TelegramLoginSigningEdDSA,
} {
entry, err := generateSigningKey(dir, algorithm, now)
if err != nil {
return err
}
manifest.Keys = append(manifest.Keys, entry)
}
if err := writeSigningManifest(dir, manifest); err != nil {
return err
}
codeID, err := newKeyID("code", now)
if err != nil {
return err
}
codeKey, err := randomBytes(32)
if err != nil {
return err
}
if err := writeCodeManifest(dir, codeManifest{
Version: 1, Active: codeID,
Keys: map[string]string{codeID: base64.RawURLEncoding.EncodeToString(codeKey)},
}); err != nil {
return err
}
pepper, err := randomBytes(32)
if err != nil {
return err
}
if err := writeExclusive(filepath.Join(dir, "client-secret-pepper"), []byte(base64.RawURLEncoding.EncodeToString(pepper)+"\n")); err != nil {
return fmt.Errorf("write client-secret pepper: %w", err)
}
_, err = telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper"))
return err
}
func rotateSigning(dir string, algorithm domain.TelegramLoginSigningAlgorithm, publishFor time.Duration, now time.Time) error {
if algorithm != domain.TelegramLoginSigningRS256 && algorithm != domain.TelegramLoginSigningES256 && algorithm != domain.TelegramLoginSigningEdDSA {
return errors.New("default keygen supports RS256, ES256, and EdDSA; ES256K requires an explicit jwx_es256k build and external JWK lifecycle")
}
path := filepath.Join(dir, "signing-keys.json")
var manifest signingManifest
if err := readStrictJSON(path, &manifest); err != nil {
return fmt.Errorf("read signing manifest: %w", err)
}
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) >= 32 {
return errors.New("signing manifest version or key count is invalid")
}
foundActive := false
kept := make([]signingManifestKey, 0, len(manifest.Keys)+1)
for _, key := range manifest.Keys {
if !key.Active && key.PublishUntil != "" {
until, err := time.Parse(time.RFC3339, key.PublishUntil)
if err != nil {
return fmt.Errorf("parse retiring key %s: %w", key.KeyID, err)
}
if !now.Before(until) {
continue
}
}
if key.Algorithm == algorithm && key.Active {
if foundActive {
return fmt.Errorf("multiple active %s keys", algorithm)
}
foundActive = true
key.Active = false
key.PublishUntil = now.Add(publishFor).UTC().Format(time.RFC3339)
}
kept = append(kept, key)
}
if !foundActive {
return fmt.Errorf("no active %s key to rotate", algorithm)
}
entry, err := generateSigningKey(dir, algorithm, now)
if err != nil {
return err
}
manifest.Keys = append(kept, entry)
return writeSigningManifest(dir, manifest)
}
func rotateCode(dir string, now time.Time) error {
path := filepath.Join(dir, "code-keys.json")
var manifest codeManifest
if err := readStrictJSON(path, &manifest); err != nil {
return fmt.Errorf("read code-key manifest: %w", err)
}
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) >= 16 {
return errors.New("code-key manifest is invalid or at its 16-key safety limit")
}
keyID, err := newKeyID("code", now)
if err != nil {
return err
}
key, err := randomBytes(32)
if err != nil {
return err
}
manifest.Active = keyID
manifest.Keys[keyID] = base64.RawURLEncoding.EncodeToString(key)
return writeCodeManifest(dir, manifest)
}
func generateSigningKey(dir string, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (signingManifestKey, error) {
keyID, err := newKeyID(strings.ToLower(string(algorithm)), now)
if err != nil {
return signingManifestKey{}, err
}
var privateKey any
switch algorithm {
case domain.TelegramLoginSigningRS256:
privateKey, err = rsa.GenerateKey(rand.Reader, 3072)
case domain.TelegramLoginSigningES256:
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case domain.TelegramLoginSigningEdDSA:
_, privateKey, err = ed25519.GenerateKey(rand.Reader)
default:
return signingManifestKey{}, fmt.Errorf("unsupported keygen algorithm %s", algorithm)
}
if err != nil {
return signingManifestKey{}, fmt.Errorf("generate %s key: %w", algorithm, err)
}
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return signingManifestKey{}, fmt.Errorf("marshal %s key: %w", algorithm, err)
}
filename := "signing-" + keyID + ".pem"
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := writeExclusive(filepath.Join(dir, filename), pemBytes); err != nil {
return signingManifestKey{}, fmt.Errorf("write %s key: %w", algorithm, err)
}
return signingManifestKey{
Algorithm: algorithm, KeyID: keyID, PrivateKeyFile: filename, Active: true,
}, nil
}
func newKeyID(prefix string, now time.Time) (string, error) {
raw, err := randomBytes(8)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", prefix, now.UTC().Format("20060102T150405Z"), base64.RawURLEncoding.EncodeToString(raw)), nil
}
func randomBytes(size int) ([]byte, error) {
raw := make([]byte, size)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("read cryptographic randomness: %w", err)
}
return raw, nil
}
func writeSigningManifest(dir string, manifest signingManifest) error {
return writeValidatedManifest(filepath.Join(dir, "signing-keys.json"), manifest, func(path string) error {
_, err := telegramlogin.LoadSigningKeyRing(path, time.Now)
return err
})
}
func writeCodeManifest(dir string, manifest codeManifest) error {
return writeValidatedManifest(filepath.Join(dir, "code-keys.json"), manifest, func(path string) error {
_, err := telegramlogin.LoadCodeSealer(path)
return err
})
}
func writeValidatedManifest(path string, value any, validate func(string) error) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal manifest: %w", err)
}
data = append(data, '\n')
temp, err := os.CreateTemp(filepath.Dir(path), ".telegram-login-manifest-*")
if err != nil {
return fmt.Errorf("create temporary manifest: %w", err)
}
tempPath := temp.Name()
defer func() { _ = os.Remove(tempPath) }()
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := validate(tempPath); err != nil {
return fmt.Errorf("validate generated manifest: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("atomically replace manifest: %w", err)
}
return nil
}
func writeExclusive(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
if _, err := file.Write(data); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
ok = true
return nil
}
func readStrictJSON(path string, target any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Size() > maxManifestBytes {
return errors.New("manifest must be a bounded regular file")
}
decoder := json.NewDecoder(io.LimitReader(file, maxManifestBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("manifest contains multiple JSON values")
}
return err
}
return nil
}

View file

@ -0,0 +1,109 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
telegramlogin "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
)
func TestInitializeAndRotateKeyFiles(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 7, 21, 1, 2, 3, 0, time.UTC)
if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err != nil {
t.Fatal(err)
}
ring, err := telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
t.Fatalf("supported algorithms = %#v", got)
}
if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil {
t.Fatal(err)
}
if _, err := telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper")); err != nil {
t.Fatal(err)
}
if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err == nil {
t.Fatal("second initialization unexpectedly overwrote keys")
}
later := now.Add(time.Minute)
if err := run(options{
mode: "rotate-signing", dir: dir, algorithm: domain.TelegramLoginSigningRS256,
publishFor: 2 * time.Hour, idTokenTTL: time.Hour, now: func() time.Time { return later },
}); err != nil {
t.Fatal(err)
}
var manifest signingManifest
readJSONForTest(t, filepath.Join(dir, "signing-keys.json"), &manifest)
active, retiring := 0, 0
for _, key := range manifest.Keys {
if key.Algorithm != domain.TelegramLoginSigningRS256 {
continue
}
if key.Active {
active++
} else if key.PublishUntil == later.Add(2*time.Hour).Format(time.RFC3339) {
retiring++
}
}
if active != 1 || retiring != 1 {
t.Fatalf("RS256 active=%d retiring=%d manifest=%#v", active, retiring, manifest)
}
ring, err = telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return later })
if err != nil {
t.Fatal(err)
}
jwks, _, err := ring.JWKS()
if err != nil {
t.Fatal(err)
}
var set struct {
Keys []json.RawMessage `json:"keys"`
}
if err := json.Unmarshal(jwks, &set); err != nil || len(set.Keys) != 4 {
t.Fatalf("JWKS key count=%d err=%v body=%s", len(set.Keys), err, jwks)
}
var before codeManifest
readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &before)
if err := run(options{mode: "rotate-code", dir: dir, now: func() time.Time { return later }}); err != nil {
t.Fatal(err)
}
var after codeManifest
readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &after)
if after.Active == before.Active || len(after.Keys) != 2 {
t.Fatalf("code ring before=%#v after=%#v", before, after)
}
if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil {
t.Fatal(err)
}
}
func TestRotateSigningRejectsTooShortRetirementWindow(t *testing.T) {
err := run(options{
mode: "rotate-signing", dir: t.TempDir(), algorithm: domain.TelegramLoginSigningRS256,
publishFor: 69 * time.Minute, idTokenTTL: time.Hour, now: time.Now,
})
if err == nil {
t.Fatal("short publish-for unexpectedly accepted")
}
}
func readJSONForTest(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatal(err)
}
}

View file

@ -44,6 +44,8 @@ type AccountRow struct {
Frozen bool
Reason string
Verified bool
Scam bool
Fake bool
PremiumUntil int64
LastActiveAt time.Time
DeviceCount int
@ -55,6 +57,8 @@ type AccountDetail struct {
About string
LastSeenAt int64
Verified bool
Scam bool
Fake bool
Support bool
Bot bool
StarsBalance int64
@ -105,28 +109,37 @@ type AuditLogRow struct {
}
type ChannelRow struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username string
Broadcast bool
Megagroup bool
Forum bool
Monoforum bool
Verified bool
Deleted bool
ParticipantsCount int
AdminsCount int
KickedCount int
BannedCount int
TopMessageID int
PinnedMessageID int
PTS int
Date int
CreatedAt time.Time
UpdatedAt time.Time
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username string
Broadcast bool
Megagroup bool
Forum bool
Monoforum bool
Verified bool
Scam bool
Fake bool
Gigagroup bool
Deleted bool
AntiSpam bool
ParticipantsHidden bool
NoForwards bool
JoinToSend bool
JoinRequest bool
SlowmodeSeconds int
ParticipantsCount int
AdminsCount int
KickedCount int
BannedCount int
TopMessageID int
PinnedMessageID int
PTS int
Date int
CreatedAt time.Time
UpdatedAt time.Time
}
type ChannelDetail struct {
@ -287,7 +300,7 @@ WITH auth AS (
GROUP BY user_id
)
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
@ -307,7 +320,7 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
out := make([]AccountRow, 0)
for rows.Next() {
var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, err
}
out = append(out, item)
@ -315,6 +328,140 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
return out, rows.Err()
}
type BotRow struct {
ID int64
Username string
FirstName string
Verified bool
Scam bool
Fake bool
System bool
OwnerUserID int64
CreatedAt time.Time
UpdatedAt time.Time
}
type BotDetail struct {
Bot BotRow
About string
Description string
OwnerUsername string
AuditLogs []AuditLogRow
}
// ListBots pages over live bot accounts (users.is_bot, not tombstoned) by
// descending id. Bots are excluded from ListAccounts, so this is the dedicated
// projection for them.
func (s *readStore) ListBots(ctx context.Context, beforeID int64, limit int) ([]BotRow, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
rows, err := s.pool.Query(ctx, `
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake,
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
FROM users u
LEFT JOIN bots b ON b.bot_user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.is_bot AND u.deleted_at IS NULL AND ($1::bigint = 0 OR u.id < $1)
ORDER BY u.id DESC
LIMIT $2`, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list bots: %w", err)
}
defer rows.Close()
out := make([]BotRow, 0, limit+1)
for rows.Next() {
var item BotRow
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, false, err
}
item.System = domain.IsSystemUserID(item.ID)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
func (s *readStore) SearchBots(ctx context.Context, q string) ([]BotRow, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
id := int64(-1)
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
id = n
}
username := strings.ToLower(strings.TrimPrefix(q, "@"))
rows, err := s.pool.Query(ctx, `
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake,
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
FROM users u
LEFT JOIN bots b ON b.bot_user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.is_bot AND u.deleted_at IS NULL AND (u.id = $1 OR lower(u.username) = $2 OR p.username_lower = $2)
ORDER BY u.id DESC
LIMIT $3`, id, username, accountSearchLimit)
if err != nil {
return nil, fmt.Errorf("search bots: %w", err)
}
defer rows.Close()
out := make([]BotRow, 0)
for rows.Next() {
var item BotRow
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
item.System = domain.IsSystemUserID(item.ID)
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) BotDetail(ctx context.Context, botUserID int64) (BotDetail, error) {
var out BotDetail
err := s.pool.QueryRow(ctx, `
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.about, u.verified, u.scam, u.fake,
COALESCE(b.owner_user_id, 0), COALESCE(b.description, ''),
u.created_at, u.updated_at
FROM users u
LEFT JOIN bots b ON b.bot_user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1 AND u.is_bot AND u.deleted_at IS NULL`, botUserID).Scan(
&out.Bot.ID, &out.Bot.Username, &out.Bot.FirstName, &out.About, &out.Bot.Verified, &out.Bot.Scam, &out.Bot.Fake,
&out.Bot.OwnerUserID, &out.Description, &out.Bot.CreatedAt, &out.Bot.UpdatedAt,
)
if err != nil {
return out, fmt.Errorf("get bot: %w", err)
}
out.Bot.System = domain.IsSystemUserID(out.Bot.ID)
if out.Bot.OwnerUserID > 0 {
var ownerUsername string
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(NULLIF(u.username, ''), p.username_lower, '')
FROM users u
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, out.Bot.OwnerUserID).Scan(&ownerUsername); err != nil && err != pgx.ErrNoRows {
return out, fmt.Errorf("get bot owner: %w", err)
} else {
out.OwnerUsername = ownerUsername
}
}
out.AuditLogs, err = s.auditLogs(ctx, botUserID)
if err != nil {
return out, err
}
return out, nil
}
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) {
q = strings.TrimSpace(q)
if q == "" {
@ -328,7 +475,8 @@ func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow,
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
FROM channels c
@ -361,7 +509,8 @@ func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
FROM channels c
@ -393,7 +542,8 @@ func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (Channel
err := s.pool.QueryRow(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at,
row_to_json(c)::jsonb
@ -437,7 +587,8 @@ func scanChannelRow(row channelScanner, item *ChannelRow) error {
func channelScanDest(item *ChannelRow) []any {
return []any{
&item.ID, &item.AccessHash, &item.CreatorUserID, &item.Title, &item.About, &item.Username,
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Deleted,
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Scam, &item.Fake, &item.Gigagroup, &item.Deleted,
&item.AntiSpam, &item.ParticipantsHidden, &item.NoForwards, &item.JoinToSend, &item.JoinRequest, &item.SlowmodeSeconds,
&item.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount,
&item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt,
}
@ -462,7 +613,7 @@ WITH auth AS (
GROUP BY user_id
)
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
auth.last_active_at, auth.device_count,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
@ -483,7 +634,7 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
out := make([]AccountRow, 0, limit+1)
for rows.Next() {
var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, false, err
}
out = append(out, item)
@ -502,7 +653,7 @@ func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDet
var out AccountDetail
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
u.about, u.last_seen_at, u.verified, u.support, u.is_bot,
u.about, u.last_seen_at, u.verified, u.scam, u.fake, u.support, u.is_bot,
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
@ -513,7 +664,7 @@ LEFT JOIN stars_balances sb ON sb.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, userID).Scan(
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot,
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Scam, &out.Fake, &out.Support, &out.Bot,
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.StarsBalance, &out.StarsGranted, &out.Account.Username,
)
if err != nil {
@ -915,3 +1066,90 @@ func prettyJSON(raw []byte) string {
}
return string(out)
}
// EmojiRow is a custom-emoji document projection for the admin emoji browser.
type EmojiRow struct {
DocumentID int64 `json:"DocumentID,string"`
Alt string
MimeType string
Size int64
SetTitle string
CreatedAt time.Time
}
const emojiListDefaultLimit = 60
const emojiListMaxLimit = 200
func scanEmojiRows(rows pgx.Rows) ([]EmojiRow, error) {
out := make([]EmojiRow, 0)
for rows.Next() {
var item EmojiRow
if err := rows.Scan(&item.DocumentID, &item.Alt, &item.MimeType, &item.Size, &item.CreatedAt, &item.SetTitle); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
const emojiSelectColumns = `d.id,
COALESCE((SELECT a->>'alt' FROM jsonb_array_elements(d.attributes) a WHERE a->>'kind' = 'custom_emoji' LIMIT 1), ''),
d.mime_type, d.size, d.created_at,
COALESCE((SELECT s.title FROM sticker_sets s WHERE s.emojis AND NOT s.deleted AND s.document_ids @> to_jsonb(d.id) LIMIT 1), '')`
// ListEmoji pages over custom-emoji documents by descending id.
func (s *readStore) ListEmoji(ctx context.Context, beforeID int64, limit int) ([]EmojiRow, bool, error) {
if limit <= 0 {
limit = emojiListDefaultLimit
}
if limit > emojiListMaxLimit {
limit = emojiListMaxLimit
}
rows, err := s.pool.Query(ctx, `
SELECT `+emojiSelectColumns+`
FROM documents d
WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb
AND ($1::bigint = 0 OR d.id < $1)
ORDER BY d.id DESC
LIMIT $2`, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list emoji: %w", err)
}
defer rows.Close()
out, err := scanEmojiRows(rows)
if err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
// SearchEmoji finds custom-emoji documents by document id or emoticon substring.
func (s *readStore) SearchEmoji(ctx context.Context, q string) ([]EmojiRow, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
id := int64(-1)
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
id = n
}
rows, err := s.pool.Query(ctx, `
SELECT `+emojiSelectColumns+`
FROM documents d
WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb
AND (d.id = $1 OR EXISTS (
SELECT 1 FROM jsonb_array_elements(d.attributes) a
WHERE a->>'kind' = 'custom_emoji' AND a->>'alt' ILIKE '%' || $2 || '%'
))
ORDER BY d.id DESC
LIMIT $3`, id, q, emojiListMaxLimit)
if err != nil {
return nil, fmt.Errorf("search emoji: %w", err)
}
defer rows.Close()
return scanEmojiRows(rows)
}

View file

@ -54,6 +54,10 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
@ -70,6 +74,18 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
@ -92,6 +108,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
@ -204,6 +221,73 @@ func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
}
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []EmojiRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchEmoji(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListEmoji(r.Context(), beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].DocumentID
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleEmojiAnimationAPI(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/emoji/%d/animation", s.cfg.AdminAPIURL, documentID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
if err != nil || len(raw) > 4<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
@ -416,6 +500,108 @@ func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(data)
}
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []BotRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchBots(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListBots(r.Context(), beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].ID
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleBotDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
botID, err := parseInt64(r.PathValue("id"))
if err != nil || botID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.BotDetail(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
type createBotAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
Name string `json:"name"`
Username string `json:"username"`
}
func (s *server) handleCreateBotAPI(w http.ResponseWriter, r *http.Request) {
var body createBotAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.CreateBotRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-bot"),
OwnerUserID: body.OwnerUserID,
Name: body.Name,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/create", req)
writeCommandResultAPI(w, result, err)
}
type deleteBotAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotUserID int64 `json:"bot_user_id"`
}
func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
var body deleteBotAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteBotRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-bot"),
BotUserID: body.BotUserID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/delete", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
@ -670,6 +856,254 @@ func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err)
}
type setUserFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetUserFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setUserFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-flags"),
UserID: body.UserID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setChannelFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetChannelFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-flags"),
ChannelID: body.ChannelID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setSupportAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Support bool `json:"support"`
}
func (s *server) handleSetSupportAPI(w http.ResponseWriter, r *http.Request) {
var body setSupportAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSupportRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-support"),
UserID: body.UserID,
Support: body.Support,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-support", req)
writeCommandResultAPI(w, result, err)
}
type setUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
}
func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-username"),
UserID: body.UserID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetUserColorAPI(w http.ResponseWriter, r *http.Request) {
var body setUserColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-color"),
UserID: body.UserID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setUserEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetUserEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setUserEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-emoji-status"),
UserID: body.UserID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelSettingsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Gigagroup *bool `json:"gigagroup,omitempty"`
AntiSpam *bool `json:"antispam,omitempty"`
ParticipantsHidden *bool `json:"participants_hidden,omitempty"`
NoForwards *bool `json:"noforwards,omitempty"`
JoinToSend *bool `json:"join_to_send,omitempty"`
JoinRequest *bool `json:"join_request,omitempty"`
SlowmodeSeconds *int `json:"slowmode_seconds,omitempty"`
}
func (s *server) handleSetChannelSettingsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelSettingsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelSettingsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-settings"),
ChannelID: body.ChannelID,
Gigagroup: body.Gigagroup,
AntiSpam: body.AntiSpam,
ParticipantsHidden: body.ParticipantsHidden,
NoForwards: body.NoForwards,
JoinToSend: body.JoinToSend,
JoinRequest: body.JoinRequest,
SlowmodeSeconds: body.SlowmodeSeconds,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-settings", req)
writeCommandResultAPI(w, result, err)
}
type setChannelUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Username string `json:"username"`
}
func (s *server) handleSetChannelUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-username"),
ChannelID: body.ChannelID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setChannelColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetChannelColorAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-color"),
ChannelID: body.ChannelID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setChannelEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetChannelEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-emoji-status"),
ChannelID: body.ChannelID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
@ -1331,6 +1765,44 @@ func (s *server) handleSetStickerSetSortOrderAPI(w http.ResponseWriter, r *http.
writeCommandResultAPI(w, result, err)
}
type giveGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id,string"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
Upgrade bool `json:"upgrade"`
ModelAttributeID int64 `json:"model_attribute_id,string"`
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
}
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
var body giveGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GiveGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "give-gift"),
SenderUserID: body.SenderUserID,
UserID: body.UserID,
ChannelID: body.ChannelID,
GiftID: body.GiftID,
HideName: body.HideName,
Message: body.Message,
Upgrade: body.Upgrade,
ModelAttributeID: body.ModelAttributeID,
PatternAttributeID: body.PatternAttributeID,
BackdropAttributeID: body.BackdropAttributeID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CBHSiG-t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-cWo3_wIf.css">
<script>
(function () {
try {
var stored = localStorage.getItem("telesrv.admin.theme");
var theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<script type="module" crossorigin src="/assets/index-ilFwQ32E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B998Ff2K.css">
</head>
<body>
<div id="root"></div>

View file

@ -5,6 +5,24 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script>
(function () {
try {
var stored = localStorage.getItem("telesrv.admin.theme");
var theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
</head>
<body>
<div id="root"></div>

View file

@ -1,7 +1,10 @@
import type {
AccountDetail,
AccountListResponse,
BotDetail,
BotListResponse,
ChannelDetail,
EmojiListResponse,
ChannelListResponse,
CommandResult,
GroupMessageDetail,
@ -58,6 +61,10 @@ export const api = {
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
message: (ownerUserID: number, msgID: number) => {
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });

View file

@ -1,4 +1,5 @@
import {
Bot,
ChevronDown,
Database,
LayoutDashboard,
@ -7,15 +8,17 @@ import {
Server,
Shield,
ShieldCheck,
Smile,
Users,
Gift,
Sticker,
Smile
Send
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { useI18n } from "../i18n";
import { LanguageSwitch, useI18n } from "../i18n";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
export function BootScreen() {
@ -77,8 +80,10 @@ export function Shell({
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
@ -127,6 +132,8 @@ export function Shell({
<h1>{routeTitle(route.path, t)}</h1>
</div>
<div className="topbar-actions">
<ThemeSwitch />
<LanguageSwitch />
<span className="actor-pill">{t("layout.actor", { actor })}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
<LogOut size={16} /> {t("layout.logout")}

View file

@ -0,0 +1,57 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useRef } from "react";
// StaticLottie renders a single (first) frame of a Lottie/TGS animation instead
// of looping it, so a grid of many stickers/emoji does not keep the canvas
// rendering and pinning the CPU. It plays only while hovered, then resets to the
// static frame. Use it for list/grid previews; keep the looping player for
// single, focused previews.
export function StaticLottie({
loader,
cacheKey,
className,
playOnHover = true,
onError
}: {
loader: () => Promise<Record<string, unknown>>;
cacheKey: string;
className?: string;
playOnHover?: boolean;
onError?: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
useEffect(() => {
let cancelled = false;
loader()
.then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: false,
animationData: structuredClone(data)
});
animation.current.goToAndStop(0, true);
})
.catch(() => onError?.());
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
function play() {
if (playOnHover) animation.current?.play();
}
function reset() {
if (playOnHover) animation.current?.goToAndStop(0, true);
}
return <div className={className} ref={host} onMouseEnter={play} onMouseLeave={reset} />;
}

View file

@ -0,0 +1,186 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
import type { ChannelRow } from "../types";
type IDKey = "user_id" | "channel_id";
// SupportAction toggles the official-support flag (users/bots only).
export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) {
const { t } = useI18n();
return (
<ActionButton
label={support ? t("attr.clearSupport") : t("attr.setSupport")}
icon={<LifeBuoy size={15} />}
tone="neutral"
path="/api/actions/set-support"
payload={() => ({ user_id: id, support: !support })}
onDone={onDone}
/>
);
}
// UsernameAction sets or clears (empty) a username.
export function UsernameAction({ idKey, id, path, current, onDone }: {
idKey: IDKey;
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [username, setUsername] = useState(current.replace(/^@/, ""));
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.username")}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
</label>
<ActionButton
label={t("attr.setUsername")}
icon={<AtSign size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, username: username.trim().replace(/^@/, "") })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [forProfile, setForProfile] = useState(false);
const [hasColor, setHasColor] = useState(true);
const [color, setColor] = useState("0");
const [bgEmoji, setBgEmoji] = useState("");
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {t("attr.forProfile")}</label>
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {t("attr.hasColor")}</label>
<label className="duration-field">
<span>{t("attr.colorIndex")}</span>
<input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label className="duration-field">
<span>{t("attr.bgEmojiID")}</span>
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
</label>
<ActionButton
label={t("attr.setColor")}
icon={<Palette size={15} />}
tone="neutral"
path={path}
payload={() => ({
[idKey]: id,
for_profile: forProfile,
has_color: hasColor,
color: toInt(color),
background_emoji_id: (bgEmoji.trim() || "0")
})}
onDone={onDone}
/>
</div>
);
}
// EmojiStatusAction sets (document id) or clears (empty) an emoji status.
export function EmojiStatusAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [documentID, setDocumentID] = useState("");
const [until, setUntil] = useState("0");
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.emojiDocID")}</span>
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
</label>
<label className="duration-field">
<span>{t("attr.emojiUntil")}</span>
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
</label>
<ActionButton
label={t("attr.setEmojiStatus")}
icon={<Smile size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, document_id: (documentID.trim() || "0"), until: toInt(until) })}
onDone={onDone}
/>
</div>
);
}
// ChannelSettingsAction force-applies moderation settings to a channel/supergroup.
export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) {
const { t } = useI18n();
const [gigagroup, setGigagroup] = useState(channel.Gigagroup);
const [antispam, setAntispam] = useState(channel.AntiSpam);
const [hidden, setHidden] = useState(channel.ParticipantsHidden);
const [noforwards, setNoforwards] = useState(channel.NoForwards);
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
// Re-sync the toggles with the persisted state whenever the channel reloads
// (e.g. after applying a change), so previously-applied settings stay checked.
useEffect(() => {
setGigagroup(channel.Gigagroup);
setAntispam(channel.AntiSpam);
setHidden(channel.ParticipantsHidden);
setNoforwards(channel.NoForwards);
setJoinToSend(channel.JoinToSend);
setJoinRequest(channel.JoinRequest);
setSlowmode(String(channel.SlowmodeSeconds));
}, [channel]);
// Send only the fields the admin actually changed. The backend applies a
// partial patch (nil = leave unchanged), so an unrelated setting is never
// reset when another one is applied.
function buildPatch() {
const patch: Record<string, unknown> = { channel_id: channel.ID };
if (gigagroup !== channel.Gigagroup) patch.gigagroup = gigagroup;
if (antispam !== channel.AntiSpam) patch.antispam = antispam;
if (hidden !== channel.ParticipantsHidden) patch.participants_hidden = hidden;
if (noforwards !== channel.NoForwards) patch.noforwards = noforwards;
if (joinToSend !== channel.JoinToSend) patch.join_to_send = joinToSend;
if (joinRequest !== channel.JoinRequest) patch.join_request = joinRequest;
if (toInt(slowmode) !== channel.SlowmodeSeconds) patch.slowmode_seconds = toInt(slowmode);
return patch;
}
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {t("attr.antispam")}</label>
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {t("attr.participantsHidden")}</label>
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {t("attr.noforwards")}</label>
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {t("attr.joinToSend")}</label>
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {t("attr.joinRequest")}</label>
<label className="duration-field">
<span>{t("attr.slowmode")}</span>
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
</label>
<ActionButton
label={t("attr.applySettings")}
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"
payload={buildPatch}
onDone={onDone}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { ShieldAlert, ShieldX } from "lucide-react";
import { useI18n } from "../i18n";
import { ActionButton } from "./ActionButton";
import { Badge } from "./ui";
// ScamFakeBadges renders the SCAM/FAKE moderation labels when set.
export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) {
const { t } = useI18n();
if (!scam && !fake) {
return null;
}
return (
<>
{scam && <Badge tone="danger">{t("flags.scam")}</Badge>}
{fake && <Badge tone="danger">{t("flags.fake")}</Badge>}
</>
);
}
// ScamFakeActions renders the two toggles. scam and fake are mutually exclusive
// (a peer is never both in Telegram), so enabling one clears the other; the
// combined setter always receives the full desired state.
export function ScamFakeActions({
idKey,
id,
path,
scam,
fake,
onDone
}: {
idKey: "user_id" | "channel_id";
id: number;
path: string;
scam: boolean;
fake: boolean;
onDone: () => void;
}) {
const { t } = useI18n();
return (
<div className="action-stack">
<ActionButton
label={scam ? t("flags.clearScam") : t("flags.setScam")}
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam: !scam, fake: !scam ? false : fake })}
onDone={onDone}
/>
<ActionButton
label={fake ? t("flags.clearFake") : t("flags.setFake")}
icon={<ShieldX size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
onDone={onDone}
/>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,15 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./theme";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<I18nProvider>
<App />
</I18nProvider>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
</ThemeProvider>
</React.StrictMode>
);

View file

@ -4,6 +4,8 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
@ -67,6 +69,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
</div>
</section>
@ -194,6 +197,12 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={account.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
@ -116,7 +117,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>

View file

@ -0,0 +1,116 @@
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotDetail } from "../types";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.bot(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
}
const bot = detail.Bot;
return (
<PageFrame
title={t("bots.detailTitle", { id: bot.ID })}
eyebrow={t("bots.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{bot.FirstName || t("bots.unnamed")}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</div>
</div>
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? t("bots.system") : t("bots.user")}</Badge>
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div>
</section>
<div className="summary-grid">
<Summary label={t("bots.botID")} value={String(bot.ID)} mono />
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} />
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} />
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
<section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("bots.actionDock")}</div>
<div className="action-stack">
<ActionButton
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
{bot.System ? (
<p className="bot-create-note">{t("bots.systemHint")}</p>
) : (
<div className="danger-zone">
<ActionButton
label={t("bots.delete")}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-bot"
payload={() => ({ bot_user_id: bot.ID })}
onDone={() => navigate("/bots")}
/>
<p className="bot-create-note">{t("bots.deleteHint")}</p>
</div>
)}
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,168 @@
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotListResponse } from "../types";
export function BotsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<BotListResponse | null>(null);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [ownerID, setOwnerID] = useState("");
const [botName, setBotName] = useState("");
const [botUsername, setBotUsername] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.bots(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
const verified = rows.filter((row) => row.Verified).length;
const systemCount = rows.filter((row) => row.System).length;
return (
<PageFrame
title={t("bots.pageTitle")}
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("bots.currentPage")} value={String(rows.length)} />
<Metric label={t("common.verified")} value={String(verified)} tone="good" />
<Metric label={t("bots.system")} value={String(systemCount)} />
</div>
<section className="section-block">
<div className="section-head">
<div>
<h2>{t("bots.createTitle")}</h2>
<p>{t("bots.createHint")}</p>
</div>
</div>
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("bots.ownerUserID")}</span>
<input
value={ownerID}
onChange={(event) => setOwnerID(event.target.value)}
type="number"
min="1"
placeholder="123456789"
/>
</label>
<label className="duration-field">
<span>{t("bots.name")}</span>
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} />
</label>
<label className="duration-field">
<span>{t("bots.username")}</span>
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
</label>
</div>
<div className="bot-create-actions">
<span className="bot-create-note">{t("bots.usernameHint")}</span>
<ActionButton
label={t("bots.create")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/create-bot"
payload={() => ({
owner_user_id: toInt(ownerID),
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
onDone={() => load(false)}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("bots.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("bots.botID")}</th>
<th>{t("common.username")}</th>
<th>{t("common.name")}</th>
<th>{t("bots.owner")}</th>
<th>{t("common.verified")}</th>
<th>{t("bots.type")}</th>
<th>{t("account.createdAt")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td>
<td>{formatDate(row.CreatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -4,6 +4,8 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
@ -51,6 +53,7 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
<Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
</div>
</section>
@ -86,6 +89,13 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{t("attr.settings")}</div>
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -2,6 +2,7 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
@ -110,7 +111,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr>

View file

@ -0,0 +1,145 @@
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types";
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function isAnimated(mime: string): boolean {
const m = mime.toLowerCase();
return m.includes("tgsticker") || m.includes("lottie") || m.includes("json");
}
function EmojiPreview({ row }: { row: EmojiRow }) {
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
useEffect(() => {
setFailed(!isAnimated(row.MimeType));
}, [row.DocumentID, row.MimeType]);
if (failed) {
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
}
// Render a static first frame (plays only on hover) so a full grid of emoji
// does not keep every Lottie canvas animating and lag the page.
return (
<StaticLottie
className="emoji-anim"
cacheKey={row.DocumentID}
loader={() => api.emojiAnimation(row.DocumentID)}
onError={() => setFailed(true)}
/>
);
}
function EmojiCard({ row }: { row: EmojiRow }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(row.DocumentID);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard is best-effort.
}
}
return (
<div className="emoji-card">
<div className="emoji-preview"><EmojiPreview row={row} /></div>
<div className="emoji-meta">
<span className="emoji-alt">{row.Alt || "—"}</span>
<button className="emoji-id" type="button" onClick={copy} title={t("emoji.copyID")}>
<span className="mono">{row.DocumentID}</span>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
<span className="emoji-sub">{row.SetTitle || t("emoji.noSet")} · {formatBytes(row.Size)}</span>
</div>
</div>
);
}
export function EmojiPage() {
const { t } = useI18n();
const [q, setQ] = useState("");
const [data, setData] = useState<EmojiListResponse | null>(null);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams();
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.emoji(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
return (
<PageFrame
title={t("emoji.pageTitle")}
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("emoji.currentPage")} value={String(rows.length)} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("emoji.searchPlaceholder")} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
</QueryPanel>
<p className="about-text">{t("emoji.hint")}</p>
{rows.length === 0 ? (
<div className="empty-panel">{t("common.noResults")}</div>
) : (
<div className="emoji-grid">
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}
</div>
)}
</PageFrame>
);
}

View file

@ -31,8 +31,38 @@ type BackdropDraft = {
let draftSequence = 0;
const nextKey = (kind: string) => `${kind}-${++draftSequence}`;
const newAnimated = (kind: string): AnimatedDraft => ({ key: nextKey(kind), name: "", rarity: "1000", sortOrder: "0", file: null, animation: null, fileError: "" });
const newBackdrop = (): BackdropDraft => ({ key: nextKey("backdrop"), name: "", backdropID: "1", rarity: "1000", sortOrder: "0", center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" });
const backdropPalettes = [
{ center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" },
{ center: "#32a86b", edge: "#17613e", pattern: "#8ee0b3", text: "#ffffff" },
{ center: "#df8d2f", edge: "#8c421e", pattern: "#ffd08a", text: "#ffffff" },
{ center: "#d95878", edge: "#7b2944", pattern: "#f5a1b6", text: "#ffffff" }
];
function rebalanceRarity<T extends { rarity: string }>(rows: T[]): T[] {
if (!rows.length) return rows;
const base = Math.floor(1000 / rows.length);
const remainder = 1000 % rows.length;
return rows.map((row, index) => ({ ...row, rarity: String(base + (index < remainder ? 1 : 0)) }));
}
const newAnimated = (kind: string, sortOrder: number): AnimatedDraft => ({
key: nextKey(kind), name: "", rarity: "1", sortOrder: String(sortOrder), file: null, animation: null, fileError: ""
});
function newBackdrop(rows: BackdropDraft[]): BackdropDraft {
const backdropID = rows.reduce((maximum, row) => {
const value = Number(row.backdropID);
return Number.isInteger(value) ? Math.max(maximum, value) : maximum;
}, 0) + 1;
const colors = backdropPalettes[rows.length % backdropPalettes.length];
return { key: nextKey("backdrop"), name: "", backdropID: String(backdropID), rarity: "1", sortOrder: String(rows.length), ...colors };
}
const initialAnimated = (kind: string) => rebalanceRarity([newAnimated(kind, 0), newAnimated(kind, 1)]);
const initialBackdrops = () => {
const first = newBackdrop([]);
return rebalanceRarity([first, newBackdrop([first])]);
};
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
@ -87,9 +117,9 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const [supplyTotal, setSupplyTotal] = useState("1000");
const [slugPrefix, setSlugPrefix] = useState(`gift-${gift.GiftID}`);
const [reason, setReason] = useState("");
const [models, setModels] = useState<AnimatedDraft[]>([newAnimated("model")]);
const [patterns, setPatterns] = useState<AnimatedDraft[]>([newAnimated("pattern")]);
const [backdrops, setBackdrops] = useState<BackdropDraft[]>([newBackdrop()]);
const [models, setModels] = useState<AnimatedDraft[]>(() => initialAnimated("model"));
const [patterns, setPatterns] = useState<AnimatedDraft[]>(() => initialAnimated("pattern"));
const [backdrops, setBackdrops] = useState<BackdropDraft[]>(initialBackdrops);
useEffect(() => {
let cancelled = false;
@ -131,6 +161,9 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
function buildForm(confirm: boolean, commandID = "") {
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error(t("collectibles.minimumAttributes"));
const backdropIDs = backdrops.map((row) => Number(row.backdropID));
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error(t("collectibles.duplicateBackdropID"));
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired"));
const form = new FormData();
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 }));
@ -168,7 +201,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] > 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 className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); 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}>
@ -178,7 +211,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label>
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
<button className="icon-btn danger" type="button" disabled={rows.length === 1} onClick={() => { setRows(rows.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)}
</div>
@ -211,7 +244,7 @@ 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 > 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-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(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); 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>
@ -220,7 +253,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<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>)}
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
<button className="icon-btn danger" type="button" disabled={backdrops.length === 1} onClick={() => { setBackdrops(backdrops.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
</div>)}</div>
</section>
</section>

View file

@ -33,7 +33,7 @@ function formatBytes(value: number | string) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
export 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);

View file

@ -0,0 +1,220 @@
import { CheckCircle2, CircleAlert, Gift, Loader2, Play, User, Users } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, JsonBlock } from "../components/ui";
import { useI18n } from "../i18n";
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
const SYSTEM_SENDER = "777000";
type RecipientKind = "user" | "channel";
function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
const rarity = attr.rarity_permille > 0 ? ` · ${(attr.rarity_permille / 10).toFixed(1)}%` : "";
return `${attr.name || `#${attr.id}`}${rarity}`;
}
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
const { t } = useI18n();
const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [message, setMessage] = useState("");
const [hideName, setHideName] = useState(false);
const [upgrade, setUpgrade] = useState(false);
const [preview, setPreview] = useState<StarGiftCollectiblePreview | null>(null);
const [previewError, setPreviewError] = useState("");
const [modelID, setModelID] = useState("0");
const [patternID, setPatternID] = useState("0");
const [backdropID, setBackdropID] = useState("0");
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const recipientID = kind === "user" ? user?.ID ?? 0 : channel?.ID ?? 0;
const upgradable = kind === "user" && upgrade;
// Reset the collectible selection whenever the chosen gift changes; the
// recipient/sender/message are intentionally preserved for fast re-issuing.
useEffect(() => {
setUpgrade(false);
setPreview(null);
setPreviewError("");
setModelID("0");
setPatternID("0");
setBackdropID("0");
setResult(null);
setError("");
}, [gift.GiftID]);
useEffect(() => {
if (!upgradable || preview) return;
let cancelled = false;
setPreviewError("");
api.giftCollectibles(gift.GiftID)
.then((data) => { if (!cancelled) setPreview(data); })
.catch((err) => { if (!cancelled) setPreviewError(errorMessage(err)); });
return () => { cancelled = true; };
}, [upgradable, preview, gift.GiftID]);
function buildPayload(confirm: boolean): Record<string, unknown> {
return {
gift_id: gift.GiftID,
// Gifts are always sent from the official system account (777000).
sender_user_id: Number(SYSTEM_SENDER),
user_id: kind === "user" ? recipientID : 0,
channel_id: kind === "channel" ? recipientID : 0,
hide_name: hideName,
message: message.trim(),
upgrade: upgradable,
model_attribute_id: upgradable ? modelID : "0",
pattern_attribute_id: upgradable ? patternID : "0",
backdrop_attribute_id: upgradable ? backdropID : "0",
reason: reason.trim(),
confirm
};
}
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
const canConfirm = result?.dry_run && !result.error;
async function run(confirm: boolean) {
if (recipientID <= 0) {
setError(t("giveGift.recipientRequired"));
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
setError("");
try {
const commandResult = await api.action("/api/actions/give-gift", buildPayload(confirm));
setResult(commandResult);
if (confirm && !commandResult.error) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="give-gift-form">
<div className="give-gift-summary">
<Gift size={16} />
<div>
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID} · {gift.Stars}</span>
</div>
</div>
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}>
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
<User size={15} /> {t("giveGift.recipientUser")}
</button>
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
<Users size={15} /> {t("giveGift.recipientChannel")}
</button>
</div>
{kind === "user"
? <UserPicker label={t("giveGift.pickUser")} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
: <ChannelPicker label={t("giveGift.pickChannel")} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</small>
</label>
<label className="form-field">
<span>{t("giveGift.message")}</span>
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
</label>
<label className="gift-switch">
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.hideName")}</span>
</label>
{kind === "user" && (
<>
<label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span>
</label>
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>}
{upgrade && previewError && <Alert>{previewError}</Alert>}
{upgrade && preview && (
<div className="gift-fields-grid give-gift-attrs">
<label>
<span>{t("giveGift.model")}</span>
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.pattern")}</span>
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.backdrop")}</span>
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
</div>
)}
</>
)}
<label className="form-field">
<span>{t("action.reason")}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
</label>
<div className="command-preview">
<div className="preview-head">{t("action.requestPreview")}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong>
</div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
<div className="give-gift-form-actions">
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")}
</button>
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<Gift size={15} />
{t("giveGift.confirm")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { Gift, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types";
import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<StarGiftRow | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const rows = (await api.gifts()).Gifts ?? [];
setGifts(rows);
setSelected((current) => current ?? rows[0] ?? null);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => { void load(); }, []);
const visible = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
return gifts.filter((gift) =>
String(gift.GiftID).includes(normalized) || gift.Title.toLowerCase().includes(normalized)
);
}, [gifts, query]);
return (
<PageFrame title={t("giveGifts.pageTitle")} eyebrow={t("giveGifts.eyebrow")} actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
}>
{error && <Alert>{error}</Alert>}
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</p>
<div className="give-gift-layout">
<section className="give-gift-picker">
<div className="give-gift-picker-head">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("giveGifts.searchPlaceholder")} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visible.length, total: gifts.length })}</span>
</div>
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}>
{visible.map((gift) => {
const active = selected?.GiftID === gift.GiftID;
return (
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
onClick={() => setSelected(gift)}>
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
<span className="give-gift-option-info">
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID}</span>
</span>
<span className="give-gift-option-price">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</button>
);
})}
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</div>}
</div>
</section>
<section className="give-gift-panel">
{selected
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{t("giveGifts.selectPrompt")}</p></div>}
</section>
</div>
</PageFrame>
);
}

View file

@ -2,7 +2,8 @@ import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import { LanguageSwitch, useI18n } from "../i18n";
import { ThemeSwitch } from "../theme";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const { t } = useI18n();
@ -36,6 +37,8 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
</span>
</div>
<div className="login-head-actions">
<ThemeSwitch />
<LanguageSwitch />
<span className="login-chip">{t("app.localAccess")}</span>
</div>
</div>

View file

@ -3,6 +3,9 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { EmojiPage } from "./EmojiPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
@ -10,21 +13,32 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/emoji") {
return <EmojiPage />;
}
if (route.path === "/gifts") {
return <GiftsPage />;
@ -32,8 +46,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
if (route.path === "/give-gifts") {
return <GiveGiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (

View file

@ -19,7 +19,10 @@ export function currentRoute(): RouteState {
export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
if (pathname.startsWith("/emoji")) return t("route.emoji");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
if (pathname.startsWith("/gifts")) return t("route.gifts");
if (pathname.startsWith("/stickers")) return t("route.stickers");
if (pathname.startsWith("/emoji")) return t("route.emoji");
@ -29,7 +32,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");

View file

@ -6,26 +6,156 @@
:root {
color-scheme: light;
--bg: #f7f9fc;
/* Surfaces */
--bg: #eef1f5;
--bg-accent: #e7ecf1;
--panel: #ffffff;
--panel-subtle: #f7f9fc;
--panel-strong: #f1f5f9;
--line: #e2e8f0;
--line-strong: #cbd5e1;
--text: #0f1720;
--muted: #64748b;
--muted-2: #94a3b8;
--brand: #2563eb;
--brand-2: #38bdf8;
--grad: linear-gradient(135deg, #38bdf8 0%, #2563eb 55%, #1e40af 100%);
--good: #167447;
--warn: #a15c07;
--danger: #b42318;
--sidebar: #08080e;
--sidebar-soft: #12121a;
--sidebar-line: #222228;
--focus: rgba(37, 99, 235, 0.16);
--shadow: 0 28px 70px -36px rgba(5, 5, 8, 0.35);
--panel-subtle: #f5f8fb;
--panel-strong: #eef2f6;
--surface-soft: #f2f7f6;
--overlay: rgba(24, 34, 47, 0.42);
--topbar-bg: rgba(255, 255, 255, 0.86);
/* Lines */
--line: #e5eaf0;
--line-strong: #d3dce4;
/* Text */
--heading: #253040;
--text: #333f4d;
--text-soft: #45525f;
--muted: #6d7885;
--muted-2: #9aa4b1;
/* Brand */
--brand: #1f7d6f;
--brand-strong: #196155;
--brand-2: #3a6cae;
--brand-tint: #e8f4f0;
--brand-tint-border: #c8e2db;
--brand-tint-text: #235d53;
/* Semantic */
--good: #1f8a57;
--good-tint: #eaf6ef;
--good-border: #c1e1cf;
--warn: #a86a12;
--warn-tint: #fcf4e4;
--warn-border: #e7d09e;
--danger: #c0392b;
--danger-tint: #fcefec;
--danger-border: #eecac3;
--danger-text: #8f2f27;
/* Accent (collectibles / craft) */
--purple: #6a4fa3;
--purple-tint: #f4effb;
--purple-border: #dcd0f0;
--purple-text: #5a4590;
/* Inputs & controls */
--input-bg: #ffffff;
--btn-bg: #ffffff;
--btn-text: #29323d;
--btn-hover: #f4f7fa;
--switch-track: #c8d0d6;
/* Code / JSON blocks */
--code-bg: #1b2733;
--code-text: #d6e3ef;
--code-border: #2b3a49;
/* Sidebar */
--sidebar: #1c2530;
--sidebar-soft: #26313d;
--sidebar-line: #313c4a;
--sidebar-row: #232d38;
--sidebar-text: #dbe3ec;
--sidebar-muted: #8b98a8;
--sidebar-faint: #7c8a9a;
--sidebar-heading: #ffffff;
/* Effects */
--focus: rgba(31, 125, 111, 0.16);
--shadow: 0 12px 34px rgba(24, 39, 56, 0.1);
--shadow-sm: 0 2px 10px rgba(24, 39, 56, 0.05);
--shadow-brand: 0 8px 22px rgba(31, 125, 111, 0.22);
/* Radii */
--radius-xs: 8px;
--radius-sm: 9px;
--radius: 11px;
--radius-lg: 14px;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #0f141a;
--bg-accent: #131a22;
--panel: #171f28;
--panel-subtle: #1c2530;
--panel-strong: #212c38;
--surface-soft: #1a232d;
--overlay: rgba(5, 8, 12, 0.62);
--topbar-bg: rgba(21, 28, 36, 0.86);
--line: #29333f;
--line-strong: #38434f;
--heading: #eef3f8;
--text: #d5dde6;
--text-soft: #c2ccd6;
--muted: #98a4b1;
--muted-2: #6d7885;
--brand: #37a596;
--brand-strong: #45b6a6;
--brand-2: #6fa8e6;
--brand-tint: #14322d;
--brand-tint-border: #245349;
--brand-tint-text: #7fd3c4;
--good: #47c281;
--good-tint: #12301f;
--good-border: #245639;
--warn: #e0aa4d;
--warn-tint: #322810;
--warn-border: #574413;
--danger: #e6695c;
--danger-tint: #35201d;
--danger-border: #5c332d;
--danger-text: #f0a49b;
--purple: #ac90e2;
--purple-tint: #221b31;
--purple-border: #3d3357;
--purple-text: #c9b6ef;
--input-bg: #131a22;
--btn-bg: #1e2731;
--btn-text: #dbe2ea;
--btn-hover: #26313d;
--switch-track: #3a454f;
--code-bg: #0c1218;
--code-text: #cdd9e5;
--code-border: #232f3b;
--sidebar: #10151b;
--sidebar-soft: #1c242f;
--sidebar-line: #262f3a;
--sidebar-row: #161d25;
--sidebar-text: #cbd4de;
--sidebar-muted: #7c8794;
--sidebar-faint: #6f7b88;
--sidebar-heading: #f0f4f8;
--focus: rgba(55, 165, 150, 0.24);
--shadow: 0 16px 40px rgba(0, 0, 0, 0.46);
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.38);
--shadow-brand: 0 8px 22px rgba(55, 165, 150, 0.26);
}
* {
@ -43,6 +173,9 @@ body {
color: var(--text);
background: var(--bg);
font: 13px/1.45 "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background-color 200ms ease, color 200ms ease;
}
button,
@ -71,7 +204,7 @@ a {
gap: 16px;
overflow-y: auto;
padding: 18px 12px;
color: #eef2f6;
color: var(--sidebar-text);
background: var(--sidebar);
border-right: 1px solid var(--sidebar-line);
}
@ -88,11 +221,20 @@ a {
justify-content: center;
}
.brand-elevated .brand-mark {
box-shadow: var(--shadow-brand);
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: #ffffff;
background: var(--brand);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
font-weight: 800;
}
.brand-mark img {
@ -111,16 +253,17 @@ a {
.brand small {
display: block;
margin-top: 3px;
color: #aeb8c4;
color: var(--sidebar-muted);
font-size: 11px;
}
.sidebar-label {
padding: 0 8px;
color: #8492a6;
color: var(--sidebar-faint);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nav-list {
@ -141,26 +284,27 @@ a {
align-items: center;
gap: 9px;
padding: 0 10px;
color: #8fa0b4;
color: var(--sidebar-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
font-weight: 800;
text-align: left;
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.nav-section-toggle:hover,
.nav-section.active .nav-section-toggle {
color: #ffffff;
color: var(--sidebar-heading);
background: var(--sidebar-soft);
border-color: #34404d;
border-color: var(--sidebar-line);
}
.nav-section-chevron {
justify-self: end;
color: #8fa0b4;
color: var(--sidebar-muted);
transition: transform 140ms ease;
}
@ -181,24 +325,25 @@ a {
align-items: center;
gap: 9px;
padding: 0 10px;
color: #c6d0dc;
color: var(--sidebar-text);
border: 1px solid transparent;
border-radius: 7px;
border-radius: var(--radius-sm);
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.nav-dot {
width: 6px;
height: 6px;
justify-self: center;
background: #687789;
background: var(--sidebar-faint);
border-radius: 999px;
}
.nav-item:hover,
.nav-item.active {
color: #ffffff;
color: var(--sidebar-heading);
background: var(--sidebar-soft);
border-color: #34404d;
border-color: var(--sidebar-line);
}
.nav-item.active .nav-dot {
@ -218,14 +363,14 @@ a {
align-items: center;
gap: 7px;
padding: 0 8px;
color: #cbd5df;
background: #171d25;
border: 1px solid #27313c;
border-radius: 7px;
color: var(--sidebar-text);
background: var(--sidebar-row);
border: 1px solid var(--sidebar-line);
border-radius: var(--radius-sm);
}
.runtime-row strong {
color: #ffffff;
color: var(--sidebar-heading);
font-size: 11px;
}
@ -243,13 +388,14 @@ a {
justify-content: space-between;
gap: 18px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.94);
background: var(--topbar-bg);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
.topbar h1 {
margin: 2px 0 0;
color: var(--heading);
font-size: 20px;
line-height: 1.2;
}
@ -266,13 +412,69 @@ a {
gap: 8px;
}
.language-switch {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 2px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.language-switch button {
min-width: 42px;
min-height: 24px;
padding: 0 9px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 800;
transition: color 140ms ease, background-color 140ms ease;
}
.language-switch button.active {
color: #ffffff;
background: var(--brand);
}
.language-switch button:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.theme-toggle {
display: inline-grid;
width: 34px;
height: 34px;
place-items: center;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
cursor: pointer;
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
}
.theme-toggle:hover {
color: var(--brand);
border-color: var(--brand-tint-border);
background: var(--brand-tint);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.actor-pill {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 10px;
color: #344054;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
@ -289,4 +491,5 @@ a {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}

View file

@ -9,7 +9,8 @@
min-width: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.overview-band {
@ -25,6 +26,7 @@
.section-head h2,
.modal h2 {
margin: 0;
color: var(--heading);
font-size: 18px;
line-height: 1.25;
}
@ -47,7 +49,7 @@
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
}
.status-item span,
@ -70,16 +72,16 @@
.status-item.good,
.metric.good {
border-color: #afd8bf;
border-color: var(--good-border);
}
.status-item.warn,
.metric.warn {
border-color: #e7c77e;
border-color: var(--warn-border);
}
.metric.danger {
border-color: #efb4ad;
border-color: var(--danger-border);
}
.command-grid {
@ -97,11 +99,15 @@
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.launcher:hover {
border-color: var(--brand);
border-color: var(--brand-tint-border);
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.launcher-icon {
@ -110,9 +116,9 @@
height: 38px;
place-items: center;
color: var(--brand);
background: #eaf2fd;
border: 1px solid #c7dcf9;
border-radius: 8px;
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: var(--radius-sm);
}
.launcher-copy {
@ -121,6 +127,7 @@
}
.launcher-copy strong {
color: var(--heading);
font-size: 15px;
}
@ -140,10 +147,10 @@
align-items: center;
gap: 8px;
padding: 0 10px;
color: #344054;
color: var(--text-soft);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-sm);
}
.page-frame {
@ -165,7 +172,7 @@
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-sm);
}
.toolbar {
@ -195,9 +202,9 @@
min-width: 0;
gap: 8px;
padding: 10px;
background: #ffffff;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-sm);
}
.picker-head {
@ -206,7 +213,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
color: #344054;
color: var(--text-soft);
font-weight: 800;
}
@ -217,10 +224,10 @@
align-items: center;
gap: 8px;
padding: 7px 9px;
color: #1e3a8a;
background: #eaf2fe;
border: 1px solid #c3d9f7;
border-radius: 7px;
color: var(--brand-tint-text);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: var(--radius-sm);
}
.selected-entity strong,
@ -237,8 +244,9 @@
}
.selected-entity div span {
color: #52606d;
color: var(--brand-tint-text);
font-size: 11px;
opacity: 0.85;
}
.picker-search {
@ -250,7 +258,7 @@
padding: 0 6px 0 9px;
background: var(--panel-subtle);
border: 1px solid var(--line-strong);
border-radius: 7px;
border-radius: var(--radius-sm);
}
.picker-search input {
@ -267,7 +275,7 @@
max-height: 236px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
}
.picker-row {
@ -278,7 +286,7 @@
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: #ffffff;
background: var(--panel);
border: 0;
border-bottom: 1px solid var(--line);
cursor: pointer;
@ -291,7 +299,7 @@
.picker-row:hover,
.picker-row.selected {
background: #eef4ff;
background: var(--surface-soft);
}
.picker-row strong,
@ -311,18 +319,24 @@
.picker-error {
color: var(--danger);
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 7px;
background: var(--danger-tint);
border: 1px solid var(--danger-border);
border-radius: var(--radius-sm);
}
input,
textarea {
color: var(--text);
background: #ffffff;
background: var(--input-bg);
border: 1px solid var(--line-strong);
border-radius: 7px;
border-radius: var(--radius-sm);
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
input::placeholder,
textarea::placeholder {
color: var(--muted-2);
}
input {
@ -379,9 +393,10 @@ textarea:focus {
width: min(380px, 100%);
height: 34px;
padding: 0 10px;
background: #ffffff;
color: var(--text);
background: var(--input-bg);
border: 1px solid var(--line-strong);
border-radius: 7px;
border-radius: var(--radius-sm);
}
.searchbox input {
@ -399,16 +414,17 @@ textarea:focus {
justify-content: center;
gap: 6px;
padding: 0 12px;
color: #1d2939;
background: #ffffff;
color: var(--btn-text);
background: var(--btn-bg);
border: 1px solid var(--line-strong);
border-radius: 7px;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.btn:hover:not(:disabled) {
background: #f7f9fb;
background: var(--btn-hover);
}
.btn:disabled {
@ -423,7 +439,8 @@ textarea:focus {
}
.btn.primary:hover:not(:disabled) {
background: #1d4ed8;
background: var(--brand-strong);
border-color: var(--brand-strong);
}
.btn.ghost {
@ -432,22 +449,24 @@ textarea:focus {
.btn.danger {
color: var(--danger);
background: #fff7f5;
border-color: #efb4ad;
background: var(--danger-tint);
border-color: var(--danger-border);
}
.btn.danger:hover:not(:disabled) {
background: #ffeceb;
background: var(--danger-tint);
border-color: var(--danger);
}
.btn.warn {
color: var(--warn);
background: #fff8ec;
border-color: #e7c77e;
background: var(--warn-tint);
border-color: var(--warn-border);
}
.btn.warn:hover:not(:disabled) {
background: #fff1d6;
background: var(--warn-tint);
border-color: var(--warn);
}
.btn:disabled,
@ -455,7 +474,7 @@ textarea:focus {
.btn.warn:disabled,
.btn.danger:disabled {
color: var(--muted-2);
background: #f3f5f7;
background: var(--panel-strong);
border-color: var(--line);
cursor: not-allowed;
}
@ -489,8 +508,9 @@ textarea:focus {
.table-wrap {
width: 100%;
overflow-x: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.data-table {
@ -513,13 +533,13 @@ textarea:focus {
position: sticky;
top: 0;
z-index: 0;
color: #475467;
color: var(--muted);
background: var(--panel-strong);
font-weight: 800;
}
.data-table tbody tr:hover {
background: #fbfcfd;
background: var(--panel-subtle);
}
.data-table tr:last-child td {
@ -541,32 +561,69 @@ textarea:focus {
min-height: 22px;
align-items: center;
padding: 1px 8px;
color: #4f5b68;
background: #f3f6f8;
border: 1px solid #d7e0e8;
color: var(--muted);
background: var(--panel-strong);
border: 1px solid var(--line-strong);
border-radius: 999px;
white-space: nowrap;
}
.badge.good {
color: var(--good);
background: #eef8f2;
border-color: #b9dcc7;
background: var(--good-tint);
border-color: var(--good-border);
}
.badge.danger {
color: var(--danger);
background: #fff2f0;
border-color: #efb4ad;
background: var(--danger-tint);
border-color: var(--danger-border);
}
.badge.warn {
color: var(--warn);
background: #fff8e7;
border-color: #e7c77e;
background: var(--warn-tint);
border-color: var(--warn-border);
}
.empty-cell {
color: var(--muted);
text-align: center;
}
.bot-create-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.bot-create-fields .duration-field input {
width: 100%;
}
.bot-create-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.bot-create-note {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 760px) {
.bot-create-fields {
grid-template-columns: 1fr;
}
.bot-create-actions {
flex-direction: column;
align-items: stretch;
}
}

View file

@ -18,10 +18,11 @@
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.entity-title {
color: var(--heading);
font-size: 20px;
font-weight: 800;
line-height: 1.25;
@ -41,10 +42,10 @@
.about-text {
margin: 0;
padding: 10px;
color: #344054;
background: #fbfcfd;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.section-block,
@ -54,7 +55,8 @@
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.section-head {
@ -79,7 +81,7 @@
.dock-title {
padding-bottom: 4px;
color: #344054;
color: var(--text-soft);
font-weight: 800;
border-bottom: 1px solid var(--line);
}
@ -184,7 +186,7 @@
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.operation-title {
@ -192,6 +194,7 @@
width: 100%;
align-items: center;
gap: 6px;
color: var(--heading);
font-weight: 800;
}
@ -212,10 +215,10 @@
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
color: #8a251d;
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 8px;
color: var(--danger-text);
background: var(--danger-tint);
border: 1px solid var(--danger-border);
border-radius: var(--radius);
}
.json-block {
@ -223,10 +226,10 @@
overflow: auto;
margin: 0;
padding: 12px;
color: #d8e6f0;
background: #141a22;
border: 1px solid #2a3542;
border-radius: 8px;
color: var(--code-text);
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: var(--radius);
font-size: 12px;
}
@ -250,12 +253,12 @@
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.gift-metrics .metric {
min-height: 68px;
padding: 12px;
background: linear-gradient(145deg, #ffffff, #f6f9f9);
background: var(--panel-subtle);
}
.gift-metrics .metric strong { font-size: 17px; }
@ -265,12 +268,12 @@
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: #eaf2fd;
border: 1px solid #c7dcf9;
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
}
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.gift-format-chips span { padding: 4px 8px; color: #1e40af; background: #eaf2fe; border: 1px solid #cbdcf7; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
.gift-format-chips span { padding: 4px 8px; color: var(--brand-tint-text); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
@ -279,6 +282,70 @@
.gift-bulk-import-modal .command-body { display: grid; gap: 14px; padding: 16px 18px; }
.gift-import-modal-body { gap: 14px; }
.gift-source-tabs { display: flex; gap: 8px; }
/* Give-gift flow */
.give-gift-summary {
display: flex; align-items: center; gap: 11px; padding: 11px 13px;
background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text-soft);
}
.give-gift-summary > svg { flex: 0 0 auto; color: var(--brand); }
.give-gift-summary strong { display: block; font-size: 13px; color: var(--text); }
.give-gift-summary .mono { font-size: 11px; color: var(--muted); }
.give-gift-tabs { display: flex; width: 100%; gap: 4px; padding: 4px; background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; }
.give-gift-tabs .btn { flex: 1 1 0; justify-content: center; min-height: 36px; border: 1px solid transparent; background: transparent; box-shadow: none; color: var(--text-soft); border-radius: 9px; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; }
.give-gift-tabs .btn:not(.primary):hover { color: var(--brand); background: var(--brand-tint); }
.give-gift-tabs .btn.primary { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
.give-gift-upgrade-note { margin: 0; padding: 9px 12px; background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 10px; color: var(--text-soft); font-size: 11px; font-weight: 650; line-height: 1.45; }
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
and site-styled selects rather than the import modal's fixed template. */
.give-gift-attrs { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs select,
.give-gift-attrs input {
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
color: var(--text); background-color: var(--input-bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
font: inherit; font-size: 12px; font-weight: 600;
appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer;
}
.give-gift-attrs input { padding-right: 10px; cursor: text; text-overflow: ellipsis; }
.give-gift-attrs select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 11px center;
}
.give-gift-attrs select:focus,
.give-gift-attrs input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
/* Give Gifts page: two-panel picker + form */
.give-gift-layout { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-picker { display: grid; gap: 10px; align-content: start; }
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
.give-gift-picker-list {
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow-y: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
}
.give-gift-option {
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
padding: 9px 11px; text-align: left; color: var(--text); background: var(--panel);
border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; box-shadow: var(--shadow-sm);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.give-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
.give-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
.give-gift-thumb { display: grid; place-items: center; width: 46px; height: 46px; }
.give-gift-thumb canvas { width: 100% !important; height: 100% !important; }
.give-gift-option-info { display: grid; gap: 3px; min-width: 0; }
.give-gift-option-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.give-gift-option-info .mono { color: var(--muted); font-size: 10px; }
.give-gift-option-price { justify-self: end; white-space: nowrap; }
.give-gift-panel {
display: grid; gap: 12px; padding: 16px; min-width: 0;
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
}
.give-gift-form { display: grid; gap: 12px; min-width: 0; }
.give-gift-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding-top: 4px; }
.give-gift-empty-panel { display: grid; gap: 10px; place-items: center; padding: 48px 20px; color: var(--muted); text-align: center; }
.give-gift-empty-panel svg { color: var(--brand); opacity: .8; }
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
.official-gift-bulk-import { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.gift-bulk-import-progress { display: flex; align-items: center; gap: 8px; min-width: 180px; }
@ -293,48 +360,48 @@
.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: #3f5a78; background: #f5f8fd; border: 1px solid #d7e2f4; border-radius: 999px;
color: var(--text-soft); background: var(--panel-subtle); border: 1px solid var(--line-strong); 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: #9dc3f5; }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(37, 99, 235, .17); }
.official-gift-categories button:hover { color: var(--brand); border-color: var(--brand-tint-border); }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
.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;
color: inherit; background: rgba(125, 140, 155, .22); border-radius: 999px; font-size: 10px;
}
.official-gift-categories button.active span { color: var(--brand); }
.official-gift-categories button.active span { color: var(--brand); background: rgba(255, 255, 255, .85); }
.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: #f5f8fd; scrollbar-gutter: stable;
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-lg);
background: var(--panel-subtle); 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 #dbe6f7; border-radius: 11px; cursor: pointer;
box-shadow: 0 1px 2px rgba(30, 41, 82, .03);
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer;
box-shadow: var(--shadow-sm);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.official-gift-option:hover { border-color: #9dc3f5; box-shadow: 0 5px 14px rgba(30, 64, 175, .08); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .12), 0 5px 14px rgba(30, 64, 175, .08); }
.official-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
.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: #5b6b85; font-size: 10px; font-weight: 700; }
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); 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-capabilities > span.yes { color: var(--good); background: var(--good-tint); border-color: var(--good-border); }
.official-gift-capabilities > span.craft { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
.official-gift-capabilities > span.no { color: var(--muted); background: var(--panel-strong); border-color: var(--line-strong); }
.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);
padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface-soft);
}
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; min-height: 96px; overflow: hidden; border-radius: 12px; }
.official-gift-selected .gift-animation { width: 96px; height: 96px; }
@ -351,23 +418,23 @@
gap: 12px;
padding: 12px 14px;
color: var(--text);
background: #ffffff;
border: 1px dashed #b7c9e8;
border-radius: 10px;
background: var(--panel);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
cursor: pointer;
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.gift-file-picker:hover,
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
.gift-file-picker.has-file { background: var(--brand-tint); border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus); }
.gift-file-picker.compact { grid-template-columns: minmax(0, 1fr); min-height: 44px; padding: 8px 12px; }
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
.gift-file-icon { width: 40px; height: 40px; border-radius: var(--radius-sm); }
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.gift-file-copy strong { overflow: hidden; color: var(--heading); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #eef4ff; border: 1px solid #c7dcf9; border-radius: 7px; font-size: 11px; font-weight: 800; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
.gift-fields-grid {
display: grid;
@ -391,26 +458,26 @@
height: 38px;
padding: 0 10px;
color: var(--text);
background: #fff;
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
}
.gift-fields-grid input:focus,
.gift-reason-field input:focus { border-color: #7bb4f0; box-shadow: 0 0 0 3px rgba(37, 99, 235, .08); outline: none; }
.gift-reason-field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: var(--text-soft); font-size: 12px; font-weight: 700; cursor: pointer; }
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: #c8d0d5; border-radius: 999px; transition: background .16s ease; }
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: var(--switch-track); border-radius: 999px; transition: background .16s ease; }
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(37, 99, 235, .16); outline-offset: 2px; }
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid var(--focus); outline-offset: 2px; }
.gift-validation { overflow: hidden; color: var(--code-text); background: var(--code-bg); border: 1px solid var(--code-border); border-radius: var(--radius-sm); }
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: var(--code-text); background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
.gift-validation-head div { display: grid; gap: 2px; }
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
.gift-validation-head span { color: var(--brand); font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: var(--code-text); font-size: 11px; }
.sticker-preview-modal { width: min(760px, 100%); }
.sticker-doc-grid {
@ -444,12 +511,12 @@
.sticker-add-form-error { flex-basis: 100%; color: var(--danger); font-size: 12px; }
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: var(--surface-soft); }
.gift-animation { width: 200px; height: 200px; }
.gift-animation canvas { width: 100% !important; height: 100% !important; }
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 50%; }
.gift-table-wrap { background: #ffffff; }
.gift-table-wrap { background: var(--panel); }
.gift-table { min-width: 1080px; }
.gift-table th:nth-child(2) { width: 74px; }
.gift-table td { vertical-align: middle; }
@ -485,7 +552,7 @@
.gift-bulk-count { color: var(--text); font-size: 12px; font-weight: 700; white-space: nowrap; }
.gift-bulk-reason { flex: 1; min-width: 160px; }
.gift-bulk-reason input { height: 34px; }
.gift-bulk-error { color: #b42318; font-size: 11px; font-weight: 700; }
.gift-bulk-error { color: var(--danger); font-size: 11px; font-weight: 700; }
.gift-page-size {
display: inline-flex;
@ -500,9 +567,9 @@
height: 30px;
padding: 0 8px;
color: var(--text);
background: #ffffff;
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
font: inherit;
font-weight: 700;
}
@ -518,7 +585,7 @@
.gift-pager-range { color: var(--muted); font-size: 11px; font-weight: 700; }
.gift-pager-controls { display: flex; align-items: center; gap: 10px; }
.gift-pager-page { color: var(--text); font-size: 12px; font-weight: 700; white-space: nowrap; }
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-sm); }
.gift-animation-shell.compact .gift-animation { width: 54px; height: 54px; }
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }
.gift-row-disabled { opacity: .68; }
@ -530,65 +597,67 @@
.gift-sort-order,
.gift-source-size,
.gift-convert-price { margin-top: 3px; color: var(--muted); font-size: 10px; }
.gift-table-price { color: #755b00; }
.gift-table-price { color: var(--warn); }
.gift-table-actions { display: flex; align-items: center; gap: 6px; }
.collectible-button { color: #6548a8; background: #f7f3ff; border-color: #ddd2f5; }
.collectible-button:hover { background: #efe8ff; border-color: #cbbaf0; }
.collectible-button { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
.collectible-button:hover { background: var(--purple-tint); border-color: var(--purple); }
.collectible-modal { width: min(1180px, 100%); max-height: min(92vh, 980px); }
.collectible-modal .modal-head p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: #f5f7fa; }
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: var(--bg); }
.collectible-loading { display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 8px; color: var(--muted); }
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: #66568c; background: linear-gradient(135deg, #fbf9ff, #f2f7ff); border: 1px dashed #cfc3e9; border-radius: 12px; }
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius); }
.collectible-empty div,
.collectible-definition-head > div:first-child,
.collectible-section-head > div:first-child { display: grid; gap: 3px; }
.collectible-empty span,
.collectible-definition-head span,
.collectible-section-head span { color: var(--muted); font-size: 10px; font-weight: 500; }
.collectible-active { overflow: hidden; background: #ffffff; border: 1px solid #ddd6ee; border-radius: 12px; box-shadow: 0 5px 16px rgba(66, 46, 110, .05); }
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(100deg, #fbf9ff, #f4f9ff); border-bottom: 1px solid #e9e4f3; }
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: #60458f; }
.collectible-active { overflow: hidden; background: var(--panel); border: 1px solid var(--purple-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--purple-tint); border-bottom: 1px solid var(--purple-border); }
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: var(--purple-text); }
.collectible-active-head > div > div { display: grid; gap: 2px; }
.collectible-active-head span { color: var(--muted); font-size: 10px; }
.collectible-active-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 1px; background: var(--line); }
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: #ffffff; }
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: var(--panel); }
.collectible-active-grid article > div:last-child { display: grid; min-width: 0; gap: 2px; }
.collectible-active-grid article strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.collectible-active-grid article span { color: var(--muted); font-size: 9px; }
.collectible-definition { overflow: hidden; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 24px rgba(16, 24, 40, .04); }
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: linear-gradient(110deg, #f8fbfa, #fbf9ff); border-bottom: 1px solid var(--line); }
.collectible-main-fields { padding: 14px 16px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
.collectible-definition { overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
.collectible-main-fields { padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
.collectible-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
.collectible-section:last-child { border-bottom: 0; }
.collectible-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.collectible-section-tools { display: flex; align-items: center; gap: 7px; }
.collectible-rows { display: grid; gap: 7px; }
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: #fafbfc; border: 1px solid #e1e6eb; border-radius: 9px; }
.collectible-row:hover { background: #ffffff; border-color: #cbd7dd; box-shadow: 0 3px 10px rgba(16, 24, 40, .035); }
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-sm); }
.collectible-row:hover { background: var(--panel); border-color: var(--line-strong); box-shadow: var(--shadow-sm); }
.collectible-row.animated { grid-template-columns: minmax(120px, 1.2fr) 90px 78px minmax(160px, 1.4fr) 48px 30px; }
.collectible-row.backdrop { grid-template-columns: minmax(110px, 1.2fr) 70px 80px 70px repeat(4, 52px) 48px 30px; }
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: #71668c; background: #f0edf7; border-right: 1px solid #e0d9ed; border-radius: 8px 0 0 8px; font-size: 10px; font-weight: 800; }
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: var(--purple-text); background: var(--purple-tint); border-right: 1px solid var(--purple-border); border-radius: var(--radius-xs) 0 0 var(--radius-xs); font-size: 10px; font-weight: 800; }
.collectible-row label { display: grid; min-width: 0; gap: 4px; }
.collectible-row label > span { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .025em; }
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: #ffffff; border: 1px solid #d5dde3; border-radius: 7px; font: inherit; font-size: 11px; }
.collectible-row input:focus { border-color: #8d7aba; box-shadow: 0 0 0 3px rgba(111, 91, 174, .08); outline: none; }
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: var(--input-bg); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); font: inherit; font-size: 11px; }
.collectible-row input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-tint); outline: none; }
.collectible-file input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: #625080; background: #f7f4fd; border: 1px dashed #cfc4e1; border-radius: 7px; font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: #8c7cae; background: radial-gradient(circle, #ffffff, #eee8f8); border: 1px solid #ded5ed; border-radius: 8px; }
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius-sm); font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: var(--purple); background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
.collectible-animation { width: 100%; height: 100%; overflow: hidden; }
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: radial-gradient(circle, #ffffff, #f0ebfa); border: 1px solid #e0d9ec; border-radius: 8px; }
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
.collectible-animation canvas { width: 100% !important; height: 100% !important; }
.collectible-animation.failed { color: #b42318; background: #fff4f2; }
.collectible-animation.loading { color: #807397; }
.collectible-file-error { grid-column: 1 / -1; color: #b42318; font-size: 10px; }
.collectible-animation.failed { color: var(--danger); background: var(--danger-tint); }
.collectible-animation.loading { color: var(--purple-text); }
.collectible-file-error { grid-column: 1 / -1; color: var(--danger); font-size: 10px; }
.collectible-color input { height: 32px !important; padding: 3px !important; cursor: pointer; }
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: var(--radius-sm); box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
.collectible-row .icon-btn { align-self: center; }
.collectible-row .icon-btn:disabled { opacity: .28; }
@media (max-width: 900px) {
.gift-fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.give-gift-layout { grid-template-columns: 1fr; }
.give-gift-picker-list { max-height: 320px; }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collectible-inline-preview,
@ -614,3 +683,113 @@
.collectible-row.backdrop { grid-template-columns: 1fr; }
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
}
.attr-block {
display: grid;
gap: 8px;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.attr-block .duration-field input {
width: 100%;
}
.attr-block .btn {
width: 100%;
justify-content: center;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.emoji-card {
display: grid;
gap: 8px;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.emoji-preview {
display: grid;
place-items: center;
height: 88px;
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.emoji-anim {
width: 80px;
height: 80px;
}
.emoji-anim canvas {
width: 100% !important;
height: 100% !important;
}
.emoji-glyph {
font-size: 46px;
line-height: 1;
}
.emoji-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.emoji-alt {
font-size: 18px;
line-height: 1.2;
}
.emoji-id {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
color: var(--text);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 11px;
}
.emoji-id .mono {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emoji-id svg {
flex: 0 0 auto;
}
.emoji-id:hover {
border-color: var(--brand-tint-border);
color: var(--brand);
}
.emoji-sub {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -5,7 +5,8 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(17, 24, 39, 0.52);
background: var(--overlay);
backdrop-filter: blur(2px);
}
.modal {
@ -13,9 +14,9 @@
max-height: min(820px, calc(100vh - 48px));
overflow: hidden;
padding: 0;
background: #ffffff;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
@ -43,10 +44,17 @@
width: 30px;
height: 30px;
place-items: center;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.icon-btn:hover {
background: var(--btn-hover);
border-color: var(--line-strong);
}
.command-steps {
@ -73,7 +81,7 @@
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-sm);
}
.command-step span {
@ -81,7 +89,7 @@
width: 20px;
height: 20px;
place-items: center;
background: #ffffff;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
@ -90,12 +98,12 @@
.command-step.active {
color: var(--brand);
border-color: #a9c8f7;
border-color: var(--brand-tint-border);
}
.command-step.done {
color: var(--good);
border-color: #b9dcc7;
border-color: var(--good-border);
}
.form-field {
@ -105,10 +113,16 @@
.form-field span,
.form-stack span {
color: #4b5563;
color: var(--text-soft);
font-weight: 800;
}
.form-field input:disabled,
.form-field textarea:disabled {
opacity: .6;
cursor: not-allowed;
}
.command-preview {
display: grid;
gap: 8px;
@ -123,7 +137,7 @@
display: flex;
align-items: center;
gap: 7px;
color: #344054;
color: var(--text-soft);
font-weight: 800;
}
@ -131,9 +145,9 @@
display: grid;
gap: 8px;
padding: 10px;
background: #fbfcfd;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
}
.result-line {
@ -151,13 +165,13 @@
}
.result-message {
color: #344054;
color: var(--text-soft);
}
.modal-actions {
justify-content: flex-end;
padding: 12px 18px;
background: #ffffff;
background: var(--panel);
border-top: 1px solid var(--line);
}
@ -174,9 +188,9 @@
width: min(420px, 100%);
gap: 18px;
padding: 22px;
background: #ffffff;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
@ -201,14 +215,15 @@
align-items: center;
padding: 0 8px;
color: var(--brand);
background: #eaf2fd;
border: 1px solid #c7dcf9;
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 999px;
font-size: 12px;
}
.login-copy h1 {
margin: 0;
color: var(--heading);
font-size: 22px;
}
@ -237,13 +252,14 @@
place-items: center;
align-content: center;
gap: 18px;
background: var(--bg);
}
.loader-bar {
width: 180px;
height: 4px;
overflow: hidden;
background: #d7dde4;
background: var(--line-strong);
border-radius: 999px;
}

View file

@ -0,0 +1,106 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Moon, Sun } from "lucide-react";
import { useI18n } from "./i18n";
export type Theme = "light" | "dark";
const storageKey = "telesrv.admin.theme";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function applyTheme(theme: Theme) {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => initialTheme());
useEffect(() => {
applyTheme(theme);
try {
localStorage.setItem(storageKey, theme);
} catch {
// Theme persistence is best-effort.
}
}, [theme]);
// Follow the OS preference until the user makes an explicit choice.
useEffect(() => {
if (!window.matchMedia) {
return;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = (event: MediaQueryListEvent) => {
let stored: string | null = null;
try {
stored = localStorage.getItem(storageKey);
} catch {
stored = null;
}
if (stored !== "light" && stored !== "dark") {
setThemeState(event.matches ? "dark" : "light");
}
};
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
const setTheme = useCallback((next: Theme) => setThemeState(next), []);
const toggleTheme = useCallback(() => setThemeState((current) => (current === "dark" ? "light" : "dark")), []);
const value = useMemo<ThemeContextValue>(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const value = useContext(ThemeContext);
if (!value) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return value;
}
export function ThemeSwitch() {
const { theme, toggleTheme } = useTheme();
const { t } = useI18n();
const nextIsDark = theme === "light";
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight");
return (
<button
className="theme-toggle"
type="button"
onClick={toggleTheme}
aria-label={label}
title={label}
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}
function initialTheme(): Theme {
try {
const stored = localStorage.getItem(storageKey);
if (stored === "light" || stored === "dark") {
return stored;
}
} catch {
// Storage is optional.
}
try {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
} catch {
// matchMedia can be unavailable in unusual embedded contexts.
}
return "light";
}

View file

@ -9,6 +9,8 @@ export type AccountRow = {
Frozen: boolean;
Reason: string;
Verified: boolean;
Scam: boolean;
Fake: boolean;
PremiumUntil: number;
LastActiveAt: string;
DeviceCount: number;
@ -59,6 +61,8 @@ export type AccountDetail = {
About: string;
LastSeenAt: number;
Verified: boolean;
Scam: boolean;
Fake: boolean;
Support: boolean;
Bot: boolean;
StarsBalance: number;
@ -81,7 +85,16 @@ export type ChannelRow = {
Forum: boolean;
Monoforum: boolean;
Verified: boolean;
Scam: boolean;
Fake: boolean;
Gigagroup: boolean;
Deleted: boolean;
AntiSpam: boolean;
ParticipantsHidden: boolean;
NoForwards: boolean;
JoinToSend: boolean;
JoinRequest: boolean;
SlowmodeSeconds: number;
ParticipantsCount: number;
AdminsCount: number;
KickedCount: number;
@ -100,6 +113,27 @@ export type ChannelDetail = {
AuditLogs: AuditLogRow[];
};
export type BotRow = {
ID: number;
Username: string;
FirstName: string;
Verified: boolean;
Scam: boolean;
Fake: boolean;
System: boolean;
OwnerUserID: number;
CreatedAt: string;
UpdatedAt: string;
};
export type BotDetail = {
Bot: BotRow;
About: string;
Description: string;
OwnerUsername: string;
AuditLogs: AuditLogRow[];
};
export type MessageRow = {
OwnerUserID: number;
BoxID: number;
@ -330,6 +364,32 @@ export type ChannelListResponse = {
listing: boolean;
};
export type BotListResponse = {
query: string;
limit: number;
rows: BotRow[];
has_more: boolean;
next_before_id: number;
listing: boolean;
};
export type EmojiRow = {
DocumentID: string;
Alt: string;
MimeType: string;
Size: number;
SetTitle: string;
CreatedAt: string;
};
export type EmojiListResponse = {
query: string;
rows: EmojiRow[];
has_more: boolean;
next_before_id: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;

View file

@ -49,6 +49,7 @@ import (
"telesrv/internal/app/stargifts"
"telesrv/internal/app/stars"
storiesapp "telesrv/internal/app/stories"
telegramloginapp "telesrv/internal/app/telegramlogin"
themesapp "telesrv/internal/app/themes"
translationapp "telesrv/internal/app/translation"
"telesrv/internal/app/updates"
@ -69,6 +70,7 @@ import (
"telesrv/internal/store/memory"
"telesrv/internal/store/postgres"
"telesrv/internal/store/redisstore"
"telesrv/internal/telegramloginhttp"
"telesrv/internal/turnsrv"
"telesrv/internal/web"
)
@ -346,12 +348,60 @@ func run(logger *zap.Logger) error {
}
defer pool.Close()
var telegramLoginService *telegramloginapp.Service
var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer
var telegramLoginHTTPHandler http.Handler
if cfg.TelegramLoginEnabled {
codeSealer, err := telegramloginapp.LoadCodeSealer(cfg.TelegramLoginCodeKeysFile)
if err != nil {
return fmt.Errorf("load telegram login code keys: %w", err)
}
clientSecretPepper, err := telegramloginapp.LoadClientSecretPepper(cfg.TelegramLoginSecretPepperFile)
if err != nil {
return fmt.Errorf("load telegram login client-secret pepper: %w", err)
}
signingKeys, err := telegramloginapp.LoadSigningKeyRing(cfg.TelegramLoginSigningKeysFile, time.Now)
if err != nil {
return fmt.Errorf("load telegram login signing keys: %w", err)
}
telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{
Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, AppLinkBase: cfg.PublicAppLinkBase,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
ClientSecretPepper: clientSecretPepper,
SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(),
RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL,
})
if err != nil {
return fmt.Errorf("initialize telegram login service: %w", err)
}
telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{
Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
if err != nil {
return fmt.Errorf("initialize telegram login ID-token issuer: %w", err)
}
}
rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil {
return fmt.Errorf("connect redis: %w", err)
}
defer func() { _ = rdb.Close() }()
logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr))
if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
if err != nil {
return fmt.Errorf("initialize telegram login HTTP provider: %w", err)
}
logger.Info("Telegram Login/OIDC provider enabled",
zap.String("issuer", telegramLoginIDTokens.Issuer()),
zap.Strings("signing_algorithms", telegramLoginIDTokens.SupportedAlgorithms()))
}
authKeyStore := postgres.NewAuthKeyStore(pool)
userStore := postgres.NewUserStore(pool)
@ -510,6 +560,7 @@ func run(logger *zap.Logger) error {
contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService),
contacts.WithAccountFreezeProvider(adminService),
contacts.WithReadModelVersions(readModelVersionStore),
)
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
@ -603,6 +654,7 @@ func run(logger *zap.Logger) error {
botsapp.WithUserCache(userCache),
botsapp.WithStickerSetCreator(filesService),
botsapp.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
@ -718,13 +770,14 @@ func run(logger *zap.Logger) error {
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService))
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService)
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithAccountFreezeProvider(adminService),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
)
@ -749,6 +802,7 @@ func run(logger *zap.Logger) error {
messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithAccountFreezeProvider(adminService),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
@ -787,6 +841,7 @@ func run(logger *zap.Logger) error {
auth.WithEmailSignup(cfg.EmailSignupEnable),
auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
rpc.SetModerationWarnings(cfg.ScamWarning, cfg.FakeWarning)
router := rpc.New(rpc.Config{
DC: cfg.DC,
IP: cfg.AdvertiseIP,
@ -805,6 +860,8 @@ func run(logger *zap.Logger) error {
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
RtmpIngestURL: cfg.LiveStreamRtmpURL,
PublicBaseURL: cfg.PublicBaseURL,
PublicAppScheme: cfg.PublicAppScheme,
PublicAppLinkBase: cfg.PublicAppLinkBase,
// PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效;
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
@ -826,6 +883,7 @@ func run(logger *zap.Logger) error {
EphemeralPush: ephemeralStore,
EphemeralReports: ephemeralReportStore,
Users: usersService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
@ -885,12 +943,16 @@ func run(logger *zap.Logger) error {
Stars: starsService,
StarsNotifier: router,
UserNotifier: router,
FreezeNotifier: router,
Channels: channelsService,
ChannelNotifier: router,
Messages: messagesService,
Gifts: giftsService,
Photos: filesService,
StickerSets: filesService,
GiftGranter: router,
Bots: botsService,
Emoji: filesService,
})
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。
@ -905,6 +967,7 @@ func run(logger *zap.Logger) error {
).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
go rpc.NewPhoneExpiryDispatcher(router, logger.Named("rpc").Named("phone-expiry"), cfg.CallExpiryInterval).Run(ctx)
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
@ -914,6 +977,10 @@ func run(logger *zap.Logger) error {
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
go router.RunAccountLifecycle(ctx, time.Minute, 500)
go router.RunAccountFreezeNotifications(ctx, time.Minute, 500)
if telegramLoginService != nil {
go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention"))
}
go func() {
interval := cfg.StarGiftSweepInterval
if interval <= 0 {
@ -953,6 +1020,7 @@ func run(logger *zap.Logger) error {
Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
AppLinkBase: cfg.PublicAppLinkBase,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
DownloadURL: cfg.PublicDownloadURL,
@ -963,6 +1031,7 @@ func run(logger *zap.Logger) error {
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
TelegramLogin: telegramLoginHTTPHandler,
}, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err)
}
@ -1014,3 +1083,49 @@ func run(logger *zap.Logger) error {
// public listener so no seed/prewarm work can run after port 2398 is exposed.
return srv.ListenAndServe(ctx, cfg.ListenAddr)
}
// telegramLoginRPCDependency preserves a disabled Telegram Login service as a
// nil interface. Assigning the nil *Service directly to rpc.Deps would create a
// non-nil interface with a nil concrete pointer and bypass Router availability
// checks.
func telegramLoginRPCDependency(service *telegramloginapp.Service) rpc.TelegramLoginService {
if service == nil {
return nil
}
return service
}
func runTelegramLoginRetention(ctx context.Context, service *telegramloginapp.Service, retention, interval time.Duration, batch int, logger *zap.Logger) {
run := func() {
var total int64
// Bound one tick even when a deployment accumulated years of stale data;
// subsequent ticks continue without monopolizing the database pool.
for range 10 {
deleted, err := service.DeleteExpiredArtifacts(ctx, time.Now().UTC().Add(-retention), batch)
if err != nil {
if ctx.Err() == nil {
logger.Warn("telegram_login_retention_failed", zap.Error(err))
}
return
}
total += deleted
if deleted < int64(batch) {
break
}
}
if total > 0 {
logger.Info("telegram_login_retention_completed", zap.Int64("deleted", total))
}
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}

21
cmd/telesrv/main_test.go Normal file
View file

@ -0,0 +1,21 @@
package main
import (
"testing"
telegramloginapp "telesrv/internal/app/telegramlogin"
)
func TestTelegramLoginRPCDependencyPreservesDisabledNil(t *testing.T) {
var disabled *telegramloginapp.Service
if dependency := telegramLoginRPCDependency(disabled); dependency != nil {
t.Fatalf("disabled Telegram Login dependency = %#v, want nil interface", dependency)
}
}
func TestTelegramLoginRPCDependencyPreservesEnabledService(t *testing.T) {
enabled := new(telegramloginapp.Service)
if dependency := telegramLoginRPCDependency(enabled); dependency != enabled {
t.Fatalf("enabled Telegram Login dependency = %#v, want %p", dependency, enabled)
}
}