Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
34
.env.example
34
.env.example
|
|
@ -101,6 +101,12 @@ TELESRV_PUBLIC_BASE_URL=https://telesrv.net
|
|||
# registered by every patched client build; tg/http/https are rejected.
|
||||
TELESRV_PUBLIC_APP_SCHEME=telesrv
|
||||
|
||||
# Optional host-based app-link root for multi-server clients. When set, public
|
||||
# links use e.g. owpg://example.com/oauth and owpg://example.com/username while
|
||||
# the scheme above remains accepted for existing/in-flight links. The value
|
||||
# must be exactly <custom-scheme>://<host>, without port/path/query/fragment.
|
||||
TELESRV_PUBLIC_APP_LINK_BASE=
|
||||
|
||||
# Web client target and display brand used by public landing pages.
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net
|
||||
TELESRV_PUBLIC_APP_NAME=telesrv
|
||||
|
|
@ -109,6 +115,14 @@ TELESRV_PUBLIC_APP_NAME=telesrv
|
|||
# landing pages' header.
|
||||
TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
|
||||
|
||||
# Profile warning text injected into getFullUser/getFullChannel About for peers
|
||||
# flagged SCAM/FAKE from the admin panel. Empty keeps built-in English defaults.
|
||||
# Clients cannot localize server text, so set your audience language here. The
|
||||
# stored bio/description is never overwritten; the warning is re-applied from the
|
||||
# flag on every read and survives the owner editing their description.
|
||||
TELESRV_SCAM_WARNING=
|
||||
TELESRV_FAKE_WARNING=
|
||||
|
||||
# Admin API / Admin UI 配置
|
||||
#
|
||||
# TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token,
|
||||
|
|
@ -188,6 +202,26 @@ TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
|||
# through nginx; public canonical URLs use TELESRV_PUBLIC_BASE_URL.
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
||||
|
||||
# Self-hosted Telegram Login / OpenID Connect. The provider is mounted on the
|
||||
# public-link listener above. Keep disabled until all three local key files
|
||||
# have been generated with `go run ./cmd/telegramloginkeygen -mode init`.
|
||||
TELESRV_TELEGRAM_LOGIN_ENABLE=false
|
||||
TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net
|
||||
# Set true to permit an HTTP issuer and HTTP registered origins/redirect URIs
|
||||
# on any hostname or IP address. HTTPS remains the default when false.
|
||||
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false
|
||||
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
|
||||
TELESRV_TELEGRAM_LOGIN_REQUEST_TTL=5m
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_TTL=2m
|
||||
TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL=1h
|
||||
# Trust only the loopback nginx hop in the documented single-host deployment.
|
||||
TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
TELESRV_TELEGRAM_LOGIN_RETENTION=168h
|
||||
TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL=5m
|
||||
TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH=500
|
||||
|
||||
# AI compose for TDesktop/Android input box rewrite/polish.
|
||||
# The local provider is deterministic and does not call external services.
|
||||
TELESRV_AI_ENABLED=true
|
||||
|
|
|
|||
|
|
@ -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`;默认保留配置,以免普通重启造成更新丢窗。
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 menu(HTML + 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
456
cmd/bots/bedolagaformat/login_demo.py
Normal file
456
cmd/bots/bedolagaformat/login_demo.py
Normal 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
|
||||
|
|
@ -1 +1,2 @@
|
|||
aiogram==3.30.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
160
cmd/bots/bedolagaformat/test_login_demo.py
Normal file
160
cmd/bots/bedolagaformat/test_login_demo.py
Normal 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()
|
||||
391
cmd/telegramloginkeygen/main.go
Normal file
391
cmd/telegramloginkeygen/main.go
Normal 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
|
||||
}
|
||||
109
cmd/telegramloginkeygen/main_test.go
Normal file
109
cmd/telegramloginkeygen/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-") {
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-B998Ff2K.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-B998Ff2K.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-ilFwQ32E.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-ilFwQ32E.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
cmd/telesrv-admin/web/dist/index.html
vendored
22
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal 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} />;
|
||||
}
|
||||
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal file
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal file
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal file
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal file
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal file
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal file
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
106
cmd/telesrv-admin/web/src/theme.tsx
Normal file
106
cmd/telesrv-admin/web/src/theme.tsx
Normal 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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
21
cmd/telesrv/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- This migration emits durable per-user edit_message events. Reverting the
|
||||
-- repaired ids or rewinding pts would reintroduce cross-account references and
|
||||
-- create holes in updates.getDifference, so rollback intentionally preserves
|
||||
-- both the corrected snapshots and their update facts.
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
-- Private message box ids are account-local. Repair user-owned Star Gift
|
||||
-- service actions that copied the owner's msg_id into both participants'
|
||||
-- message boxes, and publish durable edit_message facts for already-visible
|
||||
-- incorrect projections.
|
||||
|
||||
CREATE TEMP TABLE star_gift_box_media_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- An upgrade action points back to the original ordinary gift. user saved_id
|
||||
-- is a management identity, not a conversation message link: only the current
|
||||
-- gift owner's box may carry it. The other participant must omit the field.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT unique_box.owner_user_id,
|
||||
unique_box.box_id,
|
||||
unique_box.peer_type,
|
||||
unique_box.peer_id,
|
||||
CASE
|
||||
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
unique_box.media,
|
||||
'{service_action,star_gift_unique,saved_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
END
|
||||
FROM peer_star_gifts gift
|
||||
JOIN message_boxes upgrade_owner
|
||||
ON upgrade_owner.owner_user_id = gift.owner_peer_id
|
||||
AND upgrade_owner.box_id = gift.upgrade_msg_id
|
||||
JOIN message_boxes unique_box
|
||||
ON unique_box.message_sender_id = upgrade_owner.message_sender_id
|
||||
AND unique_box.private_message_id = upgrade_owner.private_message_id
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
AND gift.msg_id > 0
|
||||
AND gift.upgrade_msg_id > 0
|
||||
AND NOT unique_box.deleted
|
||||
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND unique_box.media #>> '{service_action,star_gift_unique,upgrade}' = 'true'
|
||||
AND unique_box.media IS DISTINCT FROM CASE
|
||||
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
unique_box.media,
|
||||
'{service_action,star_gift_unique,saved_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
END
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
-- For every other user-target unique action (transfer, resale, offer accept,
|
||||
-- craft), the action message itself is the new user saved-gift identity.
|
||||
-- saved_id is a channel-only field there and must be absent from every box.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
FROM message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'
|
||||
AND COALESCE((box.media #>> '{service_action,star_gift_unique,upgrade}')::boolean, false) = false
|
||||
AND box.media #> '{service_action,star_gift_unique,saved_id}' IS NOT NULL
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
-- A separate prepaid-upgrade action points to the same ordinary gift.
|
||||
-- Telegram defines gift_msg_id as receiver-only, so retain it only in the
|
||||
-- owner's service-message box and remove it from the payer's outgoing copy.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT prepay_box.owner_user_id,
|
||||
prepay_box.box_id,
|
||||
prepay_box.peer_type,
|
||||
prepay_box.peer_id,
|
||||
CASE
|
||||
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
prepay_box.media,
|
||||
'{service_action,star_gift,gift_msg_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
|
||||
END
|
||||
FROM peer_star_gifts gift
|
||||
JOIN message_boxes prepay_owner
|
||||
ON prepay_owner.owner_user_id = gift.owner_peer_id
|
||||
AND prepay_owner.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND prepay_owner.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND prepay_owner.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND (prepay_owner.media #>> '{service_action,star_gift,gift_msg_id}')::integer = gift.msg_id
|
||||
AND (prepay_owner.media #>> '{service_action,star_gift,gift_id}')::bigint = gift.gift_id
|
||||
JOIN message_boxes prepay_box
|
||||
ON prepay_box.message_sender_id = prepay_owner.message_sender_id
|
||||
AND prepay_box.private_message_id = prepay_owner.private_message_id
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.msg_id > 0
|
||||
AND NOT prepay_box.deleted
|
||||
AND prepay_box.media IS DISTINCT FROM CASE
|
||||
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
prepay_box.media,
|
||||
'{service_action,star_gift,gift_msg_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
|
||||
END
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
FROM star_gift_box_media_repairs
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (repair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE message_boxes
|
||||
SET media = repair.repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair.owner_user_id
|
||||
AND box_id = repair.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair.box_id, repair.peer_type, repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (repair.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- private_messages is the logical shared envelope and cannot contain either
|
||||
-- participant's local message id. User-visible history/difference always reads
|
||||
-- the per-owner message_boxes snapshots repaired above.
|
||||
UPDATE private_messages
|
||||
SET media = media
|
||||
#- '{service_action,star_gift,saved_id}'
|
||||
#- '{service_action,star_gift,gift_msg_id}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}'
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND (
|
||||
media #> '{service_action,star_gift,peer_user_id}' IS NOT NULL
|
||||
OR media #>> '{service_action,star_gift,to,Type}' = 'user'
|
||||
);
|
||||
|
||||
UPDATE private_messages
|
||||
SET media = media #- '{service_action,star_gift_unique,saved_id}'
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
DROP TABLE IF EXISTS public.telegram_login_codes;
|
||||
DROP TABLE IF EXISTS public.web_authorizations;
|
||||
DROP TABLE IF EXISTS public.telegram_login_requests;
|
||||
DROP TABLE IF EXISTS public.bot_login_native_apps;
|
||||
DROP TABLE IF EXISTS public.bot_login_allowed_urls;
|
||||
DROP TABLE IF EXISTS public.bot_login_clients;
|
||||
UPDATE public.bots
|
||||
SET commands = COALESCE((
|
||||
SELECT jsonb_agg(command ORDER BY ordinal)
|
||||
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
|
||||
WHERE command->>'command' NOT IN ('setlogin','logininfo','resetloginsecret')
|
||||
), '[]'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
253
deploy/migrations/20260714003084_telegram_login_oidc.up.sql
Normal file
253
deploy/migrations/20260714003084_telegram_login_oidc.up.sql
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
-- Telegram Login / OIDC is one durable authorization aggregate shared by the
|
||||
-- public HTTP provider and MTProto URL-auth RPCs. PostgreSQL is authoritative;
|
||||
-- Redis/NOTIFY may wake waiters but may not own any transition below.
|
||||
UPDATE public.bots
|
||||
SET commands = commands || '[
|
||||
{"command":"setlogin","description":"configure Telegram Login"},
|
||||
{"command":"logininfo","description":"show Telegram Login configuration"},
|
||||
{"command":"resetloginsecret","description":"rotate an OIDC Client Secret"}
|
||||
]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
|
||||
CREATE TABLE public.bot_login_clients (
|
||||
bot_user_id bigint PRIMARY KEY REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL UNIQUE,
|
||||
client_secret_hash bytea NOT NULL,
|
||||
secret_version bigint DEFAULT 1 NOT NULL,
|
||||
signing_algorithm text DEFAULT 'RS256'::text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_clients_client_id_check
|
||||
CHECK (client_id = bot_user_id::text AND length(client_id) BETWEEN 1 AND 64),
|
||||
CONSTRAINT bot_login_clients_secret_hash_check CHECK (octet_length(client_secret_hash) = 32),
|
||||
CONSTRAINT bot_login_clients_secret_version_check CHECK (secret_version > 0),
|
||||
CONSTRAINT bot_login_clients_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K'))
|
||||
);
|
||||
|
||||
CREATE TABLE public.bot_login_allowed_urls (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
normalized_url text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_allowed_urls_kind_check CHECK (kind IN ('web_origin','redirect_uri')),
|
||||
CONSTRAINT bot_login_allowed_urls_value_check CHECK (length(normalized_url) BETWEEN 1 AND 4096),
|
||||
UNIQUE (bot_user_id, kind, normalized_url)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_allowed_urls_bot_page_idx
|
||||
ON public.bot_login_allowed_urls(bot_user_id, kind, id);
|
||||
|
||||
CREATE TABLE public.bot_login_native_apps (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
platform text NOT NULL,
|
||||
application_id text NOT NULL,
|
||||
verification_id text NOT NULL,
|
||||
callback_uri text NOT NULL,
|
||||
verified_display_name text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_native_apps_platform_check CHECK (platform IN ('ios','android')),
|
||||
CONSTRAINT bot_login_native_apps_app_id_check
|
||||
CHECK (length(application_id) BETWEEN 3 AND 255 AND application_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'),
|
||||
CONSTRAINT bot_login_native_apps_verification_check CHECK (
|
||||
(platform = 'ios' AND verification_id ~ '^[A-Z0-9]{10}$')
|
||||
OR (platform = 'android' AND verification_id ~ '^[0-9A-F]{64}$')
|
||||
),
|
||||
CONSTRAINT bot_login_native_apps_callback_check CHECK (length(callback_uri) BETWEEN 1 AND 4096),
|
||||
CONSTRAINT bot_login_native_apps_name_check CHECK (length(btrim(verified_display_name)) BETWEEN 1 AND 128),
|
||||
UNIQUE (bot_user_id, platform, application_id, verification_id),
|
||||
UNIQUE (bot_user_id, callback_uri)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_native_apps_bot_page_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, id);
|
||||
CREATE INDEX bot_login_native_apps_callback_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, callback_uri) WHERE enabled;
|
||||
|
||||
CREATE TABLE public.telegram_login_requests (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_token_hash bytea NOT NULL UNIQUE,
|
||||
browser_token_hash bytea NOT NULL UNIQUE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL,
|
||||
signing_algorithm text NOT NULL,
|
||||
source text NOT NULL,
|
||||
response_type text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
origin text DEFAULT ''::text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
requested_scopes text[] NOT NULL,
|
||||
oauth_state text DEFAULT ''::text NOT NULL,
|
||||
nonce text DEFAULT ''::text NOT NULL,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
in_app_origin text DEFAULT ''::text NOT NULL,
|
||||
is_app boolean DEFAULT false NOT NULL,
|
||||
verified_app_name text DEFAULT ''::text NOT NULL,
|
||||
match_codes text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
match_code text DEFAULT ''::text NOT NULL,
|
||||
match_codes_first boolean DEFAULT false NOT NULL,
|
||||
user_id_hint bigint DEFAULT 0 NOT NULL,
|
||||
peer_type text DEFAULT ''::text NOT NULL,
|
||||
peer_id bigint DEFAULT 0 NOT NULL,
|
||||
message_id integer DEFAULT 0 NOT NULL,
|
||||
button_id integer DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
authorized_user_id bigint REFERENCES public.users(id),
|
||||
profile_name text DEFAULT ''::text NOT NULL,
|
||||
given_name text DEFAULT ''::text NOT NULL,
|
||||
family_name text DEFAULT ''::text NOT NULL,
|
||||
preferred_username text DEFAULT ''::text NOT NULL,
|
||||
picture text DEFAULT ''::text NOT NULL,
|
||||
phone_number text DEFAULT ''::text NOT NULL,
|
||||
write_allowed boolean DEFAULT false NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
approved_at timestamp with time zone,
|
||||
declined_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_requests_hashes_check
|
||||
CHECK (octet_length(request_token_hash) = 32 AND octet_length(browser_token_hash) = 32),
|
||||
CONSTRAINT telegram_login_requests_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')),
|
||||
CONSTRAINT telegram_login_requests_source_check
|
||||
CHECK (source IN ('web','javascript','native','mini_app','message_button')),
|
||||
CONSTRAINT telegram_login_requests_response_type_check CHECK (response_type IN ('code','post_message','legacy_url')),
|
||||
CONSTRAINT telegram_login_requests_source_response_check CHECK (
|
||||
(source = 'web' AND response_type = 'code')
|
||||
OR (source = 'javascript' AND response_type = 'post_message')
|
||||
OR (source = 'native' AND response_type = 'code')
|
||||
OR (source = 'mini_app' AND response_type = 'post_message')
|
||||
OR (source = 'message_button' AND response_type = 'legacy_url')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_url_check
|
||||
CHECK (length(redirect_uri) BETWEEN 1 AND 4096 AND length(origin) <= 4096
|
||||
AND length(domain) BETWEEN 1 AND 255 AND length(in_app_origin) <= 4096),
|
||||
CONSTRAINT telegram_login_requests_scope_check
|
||||
CHECK (cardinality(requested_scopes) BETWEEN 1 AND 4 AND requested_scopes @> ARRAY['openid']::text[]),
|
||||
CONSTRAINT telegram_login_requests_oauth_value_check
|
||||
CHECK (length(oauth_state) <= 2048 AND length(nonce) <= 1024),
|
||||
CONSTRAINT telegram_login_requests_pkce_check CHECK (
|
||||
(response_type = 'code' AND code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)
|
||||
OR (response_type = 'post_message' AND (
|
||||
(code_challenge = '' AND code_challenge_method = '')
|
||||
OR (code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)))
|
||||
OR (response_type = 'legacy_url' AND code_challenge = '' AND code_challenge_method = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_device_check
|
||||
CHECK (length(browser) BETWEEN 1 AND 255 AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT telegram_login_requests_match_codes_check
|
||||
CHECK (cardinality(match_codes) <= 8
|
||||
AND (cardinality(match_codes) = 0 OR (match_code <> '' AND match_code = ANY(match_codes)))
|
||||
AND (NOT match_codes_first OR cardinality(match_codes) > 0)),
|
||||
CONSTRAINT telegram_login_requests_context_check
|
||||
CHECK (user_id_hint >= 0 AND peer_id >= 0 AND message_id >= 0 AND button_id >= 0),
|
||||
CONSTRAINT telegram_login_requests_app_shape_check CHECK (
|
||||
(source = 'native' AND is_app AND verified_app_name <> '' AND origin = '')
|
||||
OR (source <> 'native' AND NOT is_app AND verified_app_name = '' AND origin <> '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_in_app_shape_check CHECK (
|
||||
(source = 'mini_app' AND response_type = 'post_message'
|
||||
AND in_app_origin <> '' AND origin = in_app_origin)
|
||||
OR (source <> 'mini_app' AND in_app_origin = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_consent_scope_check CHECK (
|
||||
(NOT write_allowed OR 'telegram:bot_access' = ANY(requested_scopes))
|
||||
AND (NOT phone_shared OR 'phone' = ANY(requested_scopes))
|
||||
AND ((phone_shared AND phone_number <> '') OR (NOT phone_shared AND phone_number = ''))
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_status_check CHECK (status IN ('pending','approved','declined','expired')),
|
||||
CONSTRAINT telegram_login_requests_claims_check CHECK (
|
||||
length(profile_name) <= 255 AND length(given_name) <= 255 AND length(family_name) <= 255
|
||||
AND length(preferred_username) <= 64 AND length(picture) <= 4096 AND length(phone_number) <= 32
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_time_check CHECK (expires_at > created_at),
|
||||
CONSTRAINT telegram_login_requests_terminal_shape_check CHECK (
|
||||
(status = 'pending' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
OR (status = 'approved' AND authorized_user_id IS NOT NULL AND approved_at IS NOT NULL AND declined_at IS NULL
|
||||
AND (('profile' = ANY(requested_scopes) AND profile_name <> '' AND given_name <> '')
|
||||
OR (NOT ('profile' = ANY(requested_scopes)) AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '')))
|
||||
OR (status = 'declined' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NOT NULL)
|
||||
OR (status = 'expired' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_requests_expiry_idx
|
||||
ON public.telegram_login_requests(expires_at, id) WHERE status = 'pending';
|
||||
CREATE INDEX telegram_login_requests_user_active_idx
|
||||
ON public.telegram_login_requests(authorized_user_id, approved_at DESC, id DESC)
|
||||
WHERE status = 'approved';
|
||||
|
||||
CREATE TABLE public.web_authorizations (
|
||||
hash bigint PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
domain text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
granted_scopes text[] NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
bot_access_granted boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
last_active_at timestamp with time zone NOT NULL,
|
||||
revoked_at timestamp with time zone,
|
||||
CONSTRAINT web_authorizations_hash_check CHECK (hash <> 0),
|
||||
CONSTRAINT web_authorizations_identity_check CHECK (user_id > 0 AND bot_user_id > 0),
|
||||
CONSTRAINT web_authorizations_text_check
|
||||
CHECK (length(domain) BETWEEN 1 AND 255 AND length(browser) BETWEEN 1 AND 255
|
||||
AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128
|
||||
AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT web_authorizations_scope_check
|
||||
CHECK (cardinality(granted_scopes) BETWEEN 1 AND 4 AND granted_scopes @> ARRAY['openid']::text[]
|
||||
AND (NOT phone_shared OR 'phone' = ANY(granted_scopes))
|
||||
AND (NOT bot_access_granted OR 'telegram:bot_access' = ANY(granted_scopes))),
|
||||
CONSTRAINT web_authorizations_time_check
|
||||
CHECK (last_active_at >= created_at AND (revoked_at IS NULL OR revoked_at >= created_at))
|
||||
);
|
||||
|
||||
CREATE INDEX web_authorizations_user_active_page_idx
|
||||
ON public.web_authorizations(user_id, last_active_at DESC, hash DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX web_authorizations_bot_active_idx
|
||||
ON public.web_authorizations(bot_user_id, user_id, hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE public.telegram_login_codes (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
code_hash bytea NOT NULL UNIQUE,
|
||||
sealed_code bytea NOT NULL,
|
||||
seal_nonce bytea NOT NULL,
|
||||
seal_key_id text NOT NULL,
|
||||
issued_at timestamp with time zone NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
consumed_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_codes_hash_check CHECK (octet_length(code_hash) = 32),
|
||||
CONSTRAINT telegram_login_codes_sealed_check
|
||||
CHECK (octet_length(sealed_code) >= 32 AND octet_length(seal_nonce) >= 12 AND length(seal_key_id) BETWEEN 1 AND 128),
|
||||
CONSTRAINT telegram_login_codes_time_check
|
||||
CHECK (expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at)))
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_codes_expiry_idx
|
||||
ON public.telegram_login_codes(expires_at, id) WHERE consumed_at IS NULL;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Durable edit events and repaired user gift projections are intentionally not
|
||||
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
|
||||
ALTER TABLE peer_star_gifts
|
||||
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
|
||||
|
||||
DROP TABLE IF EXISTS star_gift_user_message_refs;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
-- Official clients may continue lifecycle actions from a freshly emitted
|
||||
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
|
||||
-- durable references to the same saved gift aggregate.
|
||||
CREATE TABLE star_gift_user_message_refs (
|
||||
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
msg_id integer NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (owner_user_id, msg_id),
|
||||
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX star_gift_user_message_refs_saved_idx
|
||||
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
|
||||
|
||||
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
||||
SELECT box.owner_user_id, box.box_id, gift.id
|
||||
FROM message_boxes box
|
||||
JOIN unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN peer_star_gifts gift
|
||||
ON gift.id = unique_gift.source_saved_gift_id
|
||||
AND gift.owner_peer_type = 'user'
|
||||
AND gift.owner_peer_id = box.owner_user_id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.box_id <> gift.msg_id;
|
||||
|
||||
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
|
||||
-- also repairs historical gaps before the invariant is constrained.
|
||||
ALTER TABLE peer_star_gifts
|
||||
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
|
||||
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
|
||||
|
||||
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
|
||||
SELECT id,new_order
|
||||
FROM (
|
||||
SELECT id,
|
||||
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
|
||||
ORDER BY pinned_order,id)::integer AS new_order
|
||||
FROM peer_star_gifts
|
||||
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
|
||||
) ranked
|
||||
WHERE new_order<=6;
|
||||
|
||||
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
|
||||
|
||||
UPDATE peer_star_gifts gift
|
||||
SET pinned_order=repair.new_order
|
||||
FROM star_gift_pin_repairs repair
|
||||
WHERE gift.id=repair.id;
|
||||
|
||||
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
|
||||
-- projections set peer=user (and sometimes a user box id in saved_id), which
|
||||
-- made TDesktop select zero/stale ids instead of the emitted service message.
|
||||
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY(owner_user_id,box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
jsonb_set(
|
||||
box.media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
FROM message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
|
||||
FROM star_gift_user_unique_media_repairs
|
||||
ORDER BY owner_user_id,box_id
|
||||
LOOP
|
||||
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
|
||||
VALUES(repair.owner_user_id,0)
|
||||
ON CONFLICT(user_id) DO NOTHING;
|
||||
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts=contiguous_pts+1,updated_at=now()
|
||||
WHERE user_id=repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE message_boxes
|
||||
SET media=repair.repaired_media,pts=next_pts
|
||||
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
|
||||
|
||||
INSERT INTO user_update_events(
|
||||
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
|
||||
) VALUES(
|
||||
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO dispatch_outbox(
|
||||
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
|
||||
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE private_messages
|
||||
SET media=jsonb_set(
|
||||
media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Validation changes no data and the constraint belongs to migration 0126.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
|
||||
-- it could coexist with the deferrable unique-gift owner trigger in one
|
||||
-- migration transaction. Validate after that transaction has committed.
|
||||
ALTER TABLE peer_star_gifts
|
||||
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
-- Aggregate/message repairs and emitted edit events are authoritative business
|
||||
-- history and are intentionally not reversed. Restore only the pre-0128
|
||||
-- deferred owner guard shape.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id
|
||||
INTO saved_status, saved_owner_type, saved_owner_id
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
-- Official Android clients use a positive can_craft_at both as the Craft
|
||||
-- capability marker and as the readiness boundary. Earlier zero-delay
|
||||
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
|
||||
-- could Craft the gift but Android hid the entry entirely.
|
||||
--
|
||||
-- Block concurrent lifecycle/message writers while aggregate facts, message
|
||||
-- snapshots and durable edit edges are repaired in this migration transaction.
|
||||
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
|
||||
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Craft capability remains an intrinsic collectible fact while ownership
|
||||
-- moves between users and channels. Terminal/external states cannot Craft.
|
||||
UPDATE public.unique_star_gifts unique_gift
|
||||
SET craft_chance_permille = 0,
|
||||
updated_at = now()
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
WHERE saved_gift.unique_gift_id = unique_gift.id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
);
|
||||
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = 0
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille = 0
|
||||
AND saved_gift.can_craft_at <> 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.owner_peer_type NOT IN ('user', 'channel')
|
||||
OR saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
|
||||
END IF;
|
||||
|
||||
-- created_at is the stable persisted proxy for the original upgrade
|
||||
-- transaction date on legacy rows. New writes use the exact request date.
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = GREATEST(
|
||||
1,
|
||||
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
|
||||
)
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND saved_gift.can_craft_at = 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE (unique_gift.craft_chance_permille > 0)
|
||||
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_craft_message_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
unique_gift_id bigint NOT NULL,
|
||||
desired_craft_chance integer NOT NULL,
|
||||
desired_can_craft_at integer NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- Adding capability is owner-scoped: repair only the current owner's
|
||||
-- authoritative unique action (upgrade_msg_id) and the other visible box of
|
||||
-- that same logical private message. Never add Craft back to an old owner's
|
||||
-- historical transfer/resale action.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes owner_box
|
||||
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed craft chance';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT visible_box.owner_user_id,
|
||||
visible_box.box_id,
|
||||
unique_gift.id,
|
||||
unique_gift.craft_chance_permille,
|
||||
saved_gift.can_craft_at
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
JOIN public.message_boxes owner_box
|
||||
ON owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = owner_box.message_sender_id
|
||||
AND visible_box.private_message_id = owner_box.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND (
|
||||
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
|
||||
IS DISTINCT FROM saved_gift.can_craft_at
|
||||
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
|
||||
IS DISTINCT FROM unique_gift.craft_chance_permille
|
||||
);
|
||||
|
||||
-- Only the current owner's authoritative logical message may expose Craft.
|
||||
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
|
||||
-- and old-owner historical actions must have both wire markers removed.
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
|
||||
FROM public.message_boxes box
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND (
|
||||
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes authority
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND authority.owner_user_id = saved_gift.owner_peer_id
|
||||
AND authority.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT authority.deleted
|
||||
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
AND authority.message_sender_id = box.message_sender_id
|
||||
AND authority.private_message_id = box.private_message_id
|
||||
)
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET unique_gift_id = EXCLUDED.unique_gift_id,
|
||||
desired_craft_chance = 0,
|
||||
desired_can_craft_at = 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE box.deleted
|
||||
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.private_messages private_message
|
||||
WHERE private_message.sender_user_id = box.message_sender_id
|
||||
AND private_message.id = box.private_message_id
|
||||
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
GROUP BY box.message_sender_id, box.private_message_id
|
||||
HAVING COUNT(DISTINCT (
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at
|
||||
)) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
|
||||
repaired_media jsonb;
|
||||
repaired_private_media jsonb;
|
||||
affected_rows bigint;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT target.owner_user_id,
|
||||
target.box_id,
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
box.media
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
AND NOT box.deleted
|
||||
ORDER BY target.owner_user_id, target.box_id
|
||||
FOR UPDATE OF box
|
||||
LOOP
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repair.media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
ELSE
|
||||
repaired_media := repair.media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
END IF;
|
||||
|
||||
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
|
||||
OR (
|
||||
repair.desired_craft_chance > 0
|
||||
AND (
|
||||
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
)
|
||||
)
|
||||
OR (
|
||||
repair.desired_craft_chance = 0
|
||||
AND (
|
||||
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (repair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair.owner_user_id
|
||||
AND box_id = repair.box_id
|
||||
AND NOT deleted;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
SELECT media
|
||||
INTO repaired_private_media
|
||||
FROM public.private_messages
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id
|
||||
FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_private_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repaired_private_media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
ELSE
|
||||
repaired_private_media := repaired_private_media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE public.private_messages
|
||||
SET media = repaired_private_media
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair.box_id, repair.peer_type, repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 'edit_message', 0, 0
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
|
||||
-- and export update the two tables in separate statements, so commit-time
|
||||
-- validation observes the final atomic state without a read fallback.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
gift_crafted boolean;
|
||||
gift_craft_chance integer;
|
||||
gift_revision_id bigint;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
saved_can_craft_at integer;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted,
|
||||
craft_chance_permille, collectible_revision_id
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
|
||||
gift_craft_chance, gift_revision_id
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at
|
||||
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
IF gift_craft_chance > 0 THEN
|
||||
IF saved_can_craft_at <= 0
|
||||
OR saved_status <> 'active'
|
||||
OR saved_owner_type NOT IN ('user', 'channel')
|
||||
OR gift_owner_address <> ''
|
||||
OR gift_burned
|
||||
OR gift_crafted THEN
|
||||
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = gift_revision_id
|
||||
AND model.crafted
|
||||
) THEN
|
||||
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
|
||||
END IF;
|
||||
ELSIF saved_can_craft_at <> 0 THEN
|
||||
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE public.star_gift_craft_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
|
||||
DROP COLUMN IF EXISTS output_fingerprint,
|
||||
DROP COLUMN IF EXISTS output_media;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
-- A successful Craft outcome and its self-service message are separated by a
|
||||
-- process boundary. Freeze the exact output intent in the outcome receipt so
|
||||
-- retries never rebuild a different message from mutable gift/profile state.
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD COLUMN output_media jsonb,
|
||||
ADD COLUMN output_fingerprint bytea;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_craft_commands command
|
||||
WHERE command.success
|
||||
AND 1 <> (
|
||||
SELECT COUNT(*)
|
||||
FROM public.private_messages message
|
||||
WHERE message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND octet_length(message.request_fingerprint) = 32
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
WITH outputs AS (
|
||||
SELECT command.user_id,
|
||||
command.command_key,
|
||||
message.sender_snapshot #> '{message,Media}' AS media,
|
||||
message.request_fingerprint
|
||||
FROM public.star_gift_craft_commands command
|
||||
JOIN public.private_messages message
|
||||
ON message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
WHERE command.success
|
||||
)
|
||||
UPDATE public.star_gift_craft_commands command
|
||||
SET output_media = output.media,
|
||||
output_fingerprint = output.request_fingerprint
|
||||
FROM outputs output
|
||||
WHERE command.user_id = output.user_id
|
||||
AND command.command_key = output.command_key;
|
||||
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
|
||||
(success
|
||||
AND result_unique_gift_id IS NOT NULL
|
||||
AND output_media IS NOT NULL
|
||||
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
|
||||
AND octet_length(output_fingerprint) = 32)
|
||||
OR
|
||||
(NOT success
|
||||
AND result_unique_gift_id IS NULL
|
||||
AND output_media IS NULL
|
||||
AND output_fingerprint IS NULL)
|
||||
);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
UPDATE public.bots
|
||||
SET commands = COALESCE((
|
||||
SELECT jsonb_agg(command ORDER BY ordinal)
|
||||
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
|
||||
WHERE command->>'command' <> 'done'
|
||||
), '[]'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
|
||||
UPDATE public.users
|
||||
SET bot_info_version = bot_info_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = 93372553;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- /setlogin remains active across multiple configuration messages. Publish
|
||||
-- /done in BotFather's command menu so clients can discover the explicit
|
||||
-- finish action without reopening /help.
|
||||
UPDATE public.bots
|
||||
SET commands = commands || '[
|
||||
{"command":"done","description":"finish Telegram Login configuration"}
|
||||
]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(commands) AS item(command)
|
||||
WHERE item.command->>'command' = 'done'
|
||||
);
|
||||
|
||||
-- Bot command menus are cached by bot_info_version. Bump it even when an
|
||||
-- operator already added /done manually, making the migration convergent and
|
||||
-- forcing connected clients to refresh the authoritative command list.
|
||||
UPDATE public.users
|
||||
SET bot_info_version = bot_info_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = 93372553;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
DROP TABLE IF EXISTS public.account_freeze_notifications;
|
||||
|
||||
ALTER TABLE public.account_restrictions
|
||||
DROP CONSTRAINT IF EXISTS account_restrictions_version_check,
|
||||
DROP COLUMN IF EXISTS version;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
-- A freeze/unfreeze is a viewer-visible user projection change. Version the
|
||||
-- durable fact so a claimed old nudge can never acknowledge a newer state.
|
||||
ALTER TABLE public.account_restrictions
|
||||
ADD COLUMN version bigint DEFAULT 1 NOT NULL,
|
||||
ADD CONSTRAINT account_restrictions_version_check CHECK (version > 0);
|
||||
|
||||
-- updateUser has no pts. This coalesced queue is only a crash-safe online
|
||||
-- nudge; offline clients reconstruct the current restriction from the
|
||||
-- authoritative account_restrictions row during normal user hydration.
|
||||
CREATE TABLE public.account_freeze_notifications (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
target_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
frozen_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
version bigint NOT NULL,
|
||||
frozen boolean NOT NULL,
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
attempts integer DEFAULT 0 NOT NULL,
|
||||
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
lease_until timestamp with time zone,
|
||||
last_error text DEFAULT '' NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT account_freeze_notifications_status_check
|
||||
CHECK (status IN ('pending', 'dispatching', 'delivered')),
|
||||
CONSTRAINT account_freeze_notifications_attempts_check CHECK (attempts >= 0),
|
||||
CONSTRAINT account_freeze_notifications_version_check CHECK (version > 0),
|
||||
CONSTRAINT account_freeze_notifications_not_self_check CHECK (target_user_id <> frozen_user_id),
|
||||
UNIQUE (target_user_id, frozen_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX account_freeze_notifications_ready_idx
|
||||
ON public.account_freeze_notifications(next_attempt_at, id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX account_freeze_notifications_lease_idx
|
||||
ON public.account_freeze_notifications(lease_until, id)
|
||||
WHERE status = 'dispatching';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
DROP TRIGGER IF EXISTS star_gift_catalog_collectible_preview_activation ON public.star_gift_catalog;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_validate_collectible_preview_activation();
|
||||
|
||||
UPDATE public.star_gift_catalog c
|
||||
SET collectible_revision_id = repair.collectible_revision_id, updated_at = now()
|
||||
FROM public.star_gift_collectible_preview_repairs repair
|
||||
WHERE c.gift_id = repair.gift_id
|
||||
AND c.collectible_revision_id IS NULL;
|
||||
|
||||
DROP TABLE public.star_gift_collectible_preview_repairs;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
-- TDesktop deduplicates upgrade-preview models/patterns by document identity and can only
|
||||
-- finish each attribute spinner after it has a non-target item. Detach previously published
|
||||
-- pools that cannot satisfy that client contract; the immutable revisions remain available
|
||||
-- for audit and for already-issued unique gifts.
|
||||
CREATE TABLE public.star_gift_collectible_preview_repairs (
|
||||
gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE CASCADE,
|
||||
collectible_revision_id bigint UNIQUE NOT NULL
|
||||
REFERENCES public.star_gift_collectible_revisions(id) ON DELETE RESTRICT,
|
||||
reason text DEFAULT 'insufficient distinct upgrade preview attributes' NOT NULL,
|
||||
repaired_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO public.star_gift_collectible_preview_repairs (gift_id, collectible_revision_id)
|
||||
SELECT c.gift_id, c.collectible_revision_id
|
||||
FROM public.star_gift_catalog c
|
||||
JOIN public.star_gift_collectible_revisions r ON r.id = c.collectible_revision_id
|
||||
WHERE c.collectible_revision_id IS NOT NULL
|
||||
AND (
|
||||
r.status <> 'published' OR r.gift_id <> c.gift_id OR
|
||||
(SELECT count(DISTINCT m.document_id)
|
||||
FROM public.star_gift_collectible_models m
|
||||
WHERE m.collectible_revision_id = r.id
|
||||
AND m.rarity_kind = 'permille' AND NOT m.crafted) < 2 OR
|
||||
(SELECT count(DISTINCT p.document_id)
|
||||
FROM public.star_gift_collectible_patterns p
|
||||
WHERE p.collectible_revision_id = r.id
|
||||
AND p.rarity_kind = 'permille') < 2 OR
|
||||
(SELECT count(DISTINCT b.backdrop_id)
|
||||
FROM public.star_gift_collectible_backdrops b
|
||||
WHERE b.collectible_revision_id = r.id
|
||||
AND b.rarity_kind = 'permille') < 2
|
||||
);
|
||||
|
||||
UPDATE public.star_gift_catalog c
|
||||
SET collectible_revision_id = NULL, updated_at = now()
|
||||
FROM public.star_gift_collectible_preview_repairs repair
|
||||
WHERE c.gift_id = repair.gift_id
|
||||
AND c.collectible_revision_id = repair.collectible_revision_id;
|
||||
|
||||
-- Keep the same invariant at the final activation boundary. Application validation gives the
|
||||
-- operator a precise error first; this trigger also protects imports or maintenance SQL that
|
||||
-- attempts to expose a malformed published revision directly.
|
||||
CREATE FUNCTION public.telesrv_validate_collectible_preview_activation() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
revision_gift_id bigint;
|
||||
revision_status text;
|
||||
BEGIN
|
||||
IF NEW.collectible_revision_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT gift_id, status INTO revision_gift_id, revision_status
|
||||
FROM public.star_gift_collectible_revisions
|
||||
WHERE id = NEW.collectible_revision_id;
|
||||
|
||||
IF NOT FOUND OR revision_gift_id <> NEW.gift_id OR revision_status <> 'published' THEN
|
||||
RAISE EXCEPTION 'collectible preview revision must be published for the same gift'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT document_id)
|
||||
FROM public.star_gift_collectible_models
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille' AND NOT crafted) < 2 THEN
|
||||
RAISE EXCEPTION 'collectible model preview requires two distinct documents'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT document_id)
|
||||
FROM public.star_gift_collectible_patterns
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille') < 2 THEN
|
||||
RAISE EXCEPTION 'collectible pattern preview requires two distinct documents'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT backdrop_id)
|
||||
FROM public.star_gift_collectible_backdrops
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille') < 2 THEN
|
||||
RAISE EXCEPTION 'collectible backdrop preview requires two distinct IDs'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER star_gift_catalog_collectible_preview_activation
|
||||
BEFORE INSERT OR UPDATE OF collectible_revision_id ON public.star_gift_catalog
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_validate_collectible_preview_activation();
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.suggested_post_approvals;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
-- Durable suggested-post approval/payment/publication state. The row is the
|
||||
-- idempotency key for a monoforum suggestion; message/update rows remain the
|
||||
-- client-visible source of truth and are written in the same transaction.
|
||||
CREATE TABLE public.suggested_post_approvals (
|
||||
monoforum_id bigint NOT NULL,
|
||||
suggestion_message_id integer NOT NULL,
|
||||
parent_channel_id bigint NOT NULL,
|
||||
actor_user_id bigint NOT NULL,
|
||||
payer_user_id bigint NOT NULL,
|
||||
state text NOT NULL,
|
||||
price_kind text NOT NULL DEFAULT '',
|
||||
price_amount bigint NOT NULL DEFAULT 0,
|
||||
price_nanos integer NOT NULL DEFAULT 0,
|
||||
schedule_date integer NOT NULL DEFAULT 0,
|
||||
approval_service_message_id integer NOT NULL DEFAULT 0,
|
||||
published_message_id integer NOT NULL DEFAULT 0,
|
||||
settlement_due integer NOT NULL DEFAULT 0,
|
||||
final_service_message_id integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
PRIMARY KEY (monoforum_id, suggestion_message_id),
|
||||
CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0)
|
||||
);
|
||||
|
||||
CREATE INDEX suggested_post_approvals_schedule_idx
|
||||
ON public.suggested_post_approvals(schedule_date,monoforum_id,suggestion_message_id)
|
||||
WHERE state='scheduled';
|
||||
CREATE INDEX suggested_post_approvals_settlement_idx
|
||||
ON public.suggested_post_approvals(settlement_due,monoforum_id,suggestion_message_id)
|
||||
WHERE state='published';
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
-- The data backfill is intentionally retained on rollback. Restore only the
|
||||
-- pre-0134 shape constraint, which allowed zero schedule_date in every state.
|
||||
ALTER TABLE suggested_post_approvals
|
||||
DROP CONSTRAINT suggested_post_approvals_shape_check;
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0
|
||||
);
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
-- TDesktop omits schedule_date for "Publish Now", while the approval action
|
||||
-- renderer always formats an absolute publication date. Backfill rows written
|
||||
-- by the initial lifecycle implementation and keep current/history/difference
|
||||
-- projections on the same effective timestamp.
|
||||
UPDATE channel_messages m
|
||||
SET suggested_post = jsonb_set(
|
||||
m.suggested_post,
|
||||
'{ScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND m.channel_id = a.monoforum_id
|
||||
AND m.id = a.suggestion_message_id
|
||||
AND COALESCE((m.suggested_post->>'Accepted')::boolean, false)
|
||||
AND COALESCE((m.suggested_post->>'ScheduleDate')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_messages m
|
||||
SET action = jsonb_set(
|
||||
m.action,
|
||||
'{SuggestedPostScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND m.channel_id = a.monoforum_id
|
||||
AND m.id = a.approval_service_message_id
|
||||
AND m.action->>'Type' = 'suggested_post_approval'
|
||||
AND NOT COALESCE((m.action->>'SuggestedPostRejected')::boolean, false)
|
||||
AND NOT COALESCE((m.action->>'SuggestedPostBalanceTooLow')::boolean, false)
|
||||
AND COALESCE((m.action->>'SuggestedPostScheduleDate')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_update_events e
|
||||
SET payload = jsonb_set(
|
||||
e.payload,
|
||||
'{message,SuggestedPost,ScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND e.channel_id = a.monoforum_id
|
||||
AND e.message_id = a.suggestion_message_id
|
||||
AND e.event_type = 'edit_channel_message'
|
||||
AND COALESCE((e.payload #>> '{message,SuggestedPost,Accepted}')::boolean, false)
|
||||
AND COALESCE((e.payload #>> '{message,SuggestedPost,ScheduleDate}')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_update_events e
|
||||
SET payload = jsonb_set(
|
||||
e.payload,
|
||||
'{message,Action,SuggestedPostScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND e.channel_id = a.monoforum_id
|
||||
AND e.message_id = a.approval_service_message_id
|
||||
AND e.event_type = 'new_channel_message'
|
||||
AND e.payload #>> '{message,Action,Type}' = 'suggested_post_approval'
|
||||
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostRejected}')::boolean, false)
|
||||
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostBalanceTooLow}')::boolean, false)
|
||||
AND COALESCE((e.payload #>> '{message,Action,SuggestedPostScheduleDate}')::integer, 0) = 0;
|
||||
|
||||
UPDATE suggested_post_approvals
|
||||
SET schedule_date = created_at,
|
||||
updated_at = GREATEST(updated_at, created_at)
|
||||
WHERE schedule_date = 0
|
||||
AND state IN ('scheduled', 'published', 'completed', 'refunded');
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
DROP CONSTRAINT suggested_post_approvals_shape_check;
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND
|
||||
(state IN ('balance_low','rejected') OR schedule_date>0) AND
|
||||
approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0
|
||||
);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- The up migration registers protocol identities and emits durable per-user
|
||||
-- edit_message events. Removing aliases, reverting snapshots or rewinding pts
|
||||
-- would invalidate messages already consumed by clients and create holes in
|
||||
-- updates.getDifference, so rollback intentionally preserves the repair.
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
-- A separate prepaid-upgrade service message is another owner-local entry to
|
||||
-- the same saved-gift aggregate. Earlier writes persisted gift_msg_id in the
|
||||
-- receiver projection but did not register that message id, so clients that
|
||||
-- submitted the visible card id received STARGIFT_INVALID. If the gift was
|
||||
-- upgraded through the original id, the prepaid card also remained actionable.
|
||||
--
|
||||
-- Repair aliases and already-upgraded projections atomically. Durable edit
|
||||
-- events make history, online delivery and updates.getDifference converge on
|
||||
-- the same non-actionable snapshot. Invalid persisted shapes fail the migration
|
||||
-- instead of being normalized by a read path.
|
||||
|
||||
LOCK TABLE public.peer_star_gifts, public.star_gift_user_message_refs,
|
||||
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift,gift_id}') IS DISTINCT FROM 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift,gift_id}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric <= 0
|
||||
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric > 9223372036854775807
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_id';
|
||||
END IF;
|
||||
|
||||
-- gift_msg_id is receiver-only, so absence is valid on the payer box. If
|
||||
-- present it must be a positive protocol int32 message id.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift,gift_msg_id}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift,gift_msg_id}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric <= 0
|
||||
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric > 2147483647
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_msg_id';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_message_aliases ON COMMIT DROP AS
|
||||
SELECT DISTINCT owner_box.owner_user_id,
|
||||
owner_box.box_id,
|
||||
gift.id AS saved_gift_id,
|
||||
owner_box.message_sender_id,
|
||||
owner_box.private_message_id
|
||||
FROM public.message_boxes owner_box
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.owner_peer_type = 'user'
|
||||
AND gift.owner_peer_id = owner_box.owner_user_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.msg_id = (owner_box.media #>> '{service_action,star_gift,gift_msg_id}')::integer
|
||||
AND gift.gift_id = (owner_box.media #>> '{service_action,star_gift,gift_id}')::bigint
|
||||
WHERE NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND owner_box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND owner_box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND owner_box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases
|
||||
GROUP BY owner_user_id, box_id
|
||||
HAVING COUNT(DISTINCT saved_gift_id) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message resolves to multiple aggregates';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.star_gift_user_message_refs ref
|
||||
ON ref.owner_user_id = alias.owner_user_id
|
||||
AND ref.msg_id = alias.box_id
|
||||
WHERE ref.saved_gift_id <> alias.saved_gift_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message collides with another aggregate';
|
||||
END IF;
|
||||
|
||||
-- Both boxes of the logical private message must retain the same prepayment
|
||||
-- identity. The receiver-only gift_msg_id may differ by design.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift ON gift.id = alias.saved_gift_id
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = alias.message_sender_id
|
||||
AND visible_box.private_message_id = alias.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
WHERE visible_box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift'
|
||||
OR visible_box.media #>> '{service_action,star_gift,prepaid_upgrade}' IS DISTINCT FROM 'true'
|
||||
OR visible_box.media #>> '{service_action,star_gift,upgrade_separate}' IS DISTINCT FROM 'true'
|
||||
OR visible_box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM gift.gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift private projections disagree';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX star_gift_prepaid_message_aliases_owner_msg_idx
|
||||
ON star_gift_prepaid_message_aliases(owner_user_id, box_id);
|
||||
|
||||
INSERT INTO public.star_gift_user_message_refs(owner_user_id, msg_id, saved_gift_id)
|
||||
SELECT owner_user_id, box_id, saved_gift_id
|
||||
FROM star_gift_prepaid_message_aliases
|
||||
ON CONFLICT (owner_user_id, msg_id) DO UPDATE
|
||||
SET saved_gift_id = EXCLUDED.saved_gift_id
|
||||
WHERE star_gift_user_message_refs.saved_gift_id = EXCLUDED.saved_gift_id;
|
||||
|
||||
COMMENT ON TABLE public.star_gift_user_message_refs IS
|
||||
'Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.';
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_message_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
message_sender_id bigint NOT NULL,
|
||||
private_message_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- Upgrade every visible copy of an already-consumed prepayment. A viewer gets
|
||||
-- upgrade_msg_id only when that same viewer owns a box for the emitted unique
|
||||
-- action. This covers the original sender while avoiding an owner-local link
|
||||
-- on an unrelated third-party payer's card.
|
||||
INSERT INTO star_gift_prepaid_message_repairs(
|
||||
owner_user_id, box_id, peer_type, peer_id,
|
||||
message_sender_id, private_message_id, repaired_media
|
||||
)
|
||||
SELECT visible_box.owner_user_id,
|
||||
visible_box.box_id,
|
||||
visible_box.peer_type,
|
||||
visible_box.peer_id,
|
||||
visible_box.message_sender_id,
|
||||
visible_box.private_message_id,
|
||||
CASE
|
||||
WHEN unique_box.box_id IS NULL THEN
|
||||
visible_box.media
|
||||
#- '{service_action,star_gift,can_upgrade}'
|
||||
#- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}'
|
||||
ELSE jsonb_set(
|
||||
visible_box.media
|
||||
#- '{service_action,star_gift,can_upgrade}'
|
||||
#- '{service_action,star_gift,prepaid_upgrade_hash}',
|
||||
'{service_action,star_gift,upgrade_msg_id}',
|
||||
to_jsonb(unique_box.box_id::bigint),
|
||||
true
|
||||
)
|
||||
END
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.id = alias.saved_gift_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
AND gift.upgrade_msg_id > 0
|
||||
JOIN public.message_boxes owner_unique_box
|
||||
ON owner_unique_box.owner_user_id = gift.owner_peer_id
|
||||
AND owner_unique_box.box_id = gift.upgrade_msg_id
|
||||
AND NOT owner_unique_box.deleted
|
||||
AND owner_unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND owner_unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = alias.message_sender_id
|
||||
AND visible_box.private_message_id = alias.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
LEFT JOIN public.message_boxes unique_box
|
||||
ON unique_box.owner_user_id = visible_box.owner_user_id
|
||||
AND unique_box.message_sender_id = owner_unique_box.message_sender_id
|
||||
AND unique_box.private_message_id = owner_unique_box.private_message_id
|
||||
AND NOT unique_box.deleted
|
||||
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair_row record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.id = alias.saved_gift_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_repairs target_repair
|
||||
WHERE target_repair.owner_user_id = alias.owner_user_id
|
||||
AND target_repair.box_id = alias.box_id
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'upgraded star gift is missing its prepaid message repair';
|
||||
END IF;
|
||||
|
||||
FOR repair_row IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
FROM star_gift_prepaid_message_repairs
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO public.user_update_watermarks(user_id, contiguous_pts)
|
||||
VALUES(repair_row.owner_user_id, 0)
|
||||
ON CONFLICT(user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair_row.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repair_row.repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair_row.owner_user_id
|
||||
AND box_id = repair_row.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO public.user_update_events(
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair_row.box_id, repair_row.peer_type, repair_row.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox(
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- private_messages is a shared logical envelope and cannot retain either
|
||||
-- participant's box-local gift_msg_id or upgrade_msg_id.
|
||||
WITH shared_repairs AS (
|
||||
SELECT DISTINCT ON (repair.message_sender_id, repair.private_message_id)
|
||||
repair.message_sender_id,
|
||||
repair.private_message_id,
|
||||
repair.repaired_media
|
||||
#- '{service_action,star_gift,saved_id}'
|
||||
#- '{service_action,star_gift,gift_msg_id}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}' AS shared_media
|
||||
FROM star_gift_prepaid_message_repairs repair
|
||||
ORDER BY repair.message_sender_id,
|
||||
repair.private_message_id,
|
||||
(repair.owner_user_id = repair.message_sender_id) DESC,
|
||||
repair.owner_user_id
|
||||
)
|
||||
UPDATE public.private_messages private_message
|
||||
SET media = repair.shared_media
|
||||
FROM shared_repairs repair
|
||||
WHERE private_message.sender_user_id = repair.message_sender_id
|
||||
AND private_message.id = repair.private_message_id;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
LEFT JOIN public.star_gift_user_message_refs ref
|
||||
ON ref.owner_user_id = alias.owner_user_id
|
||||
AND ref.msg_id = alias.box_id
|
||||
AND ref.saved_gift_id = alias.saved_gift_id
|
||||
WHERE ref.saved_gift_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift alias repair did not converge';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_repairs repair
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = repair.owner_user_id
|
||||
AND box.box_id = repair.box_id
|
||||
WHERE box.media IS DISTINCT FROM repair.repaired_media
|
||||
OR box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'upgraded prepaid star gift projection repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
19
deploy/migrations/20260714003095_scam_fake_flags.up.sql
Normal file
19
deploy/migrations/20260714003095_scam_fake_flags.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- SCAM / FAKE moderation flags for users (incl. bots) and channels.
|
||||
-- Mirrors the Layer 228 user.scam/user.fake and channel.scam/channel.fake TL flags.
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
|
||||
|
||||
UPDATE public.users SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT users_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
|
||||
ALTER TABLE public.channels
|
||||
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
|
||||
|
||||
UPDATE public.channels SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT channels_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP COLUMN IF EXISTS gigagroup;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- gigagroup flag for supergroups (Layer 228 channel.gigagroup).
|
||||
ALTER TABLE public.channels
|
||||
ADD COLUMN IF NOT EXISTS gigagroup boolean DEFAULT false NOT NULL;
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- Direct admin collectible grants are one idempotent aggregate: unique
|
||||
-- issuance, saved ownership, private message, pts/outbox and this receipt.
|
||||
CREATE TABLE public.star_gift_admin_grant_commands (
|
||||
recipient_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
request_fingerprint bytea NOT NULL,
|
||||
sender_user_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_admin_grant_commands_pkey PRIMARY KEY (recipient_user_id, command_key),
|
||||
CONSTRAINT star_gift_admin_grant_command_saved_uniq UNIQUE (saved_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_unique_uniq UNIQUE (unique_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_shape_check CHECK (
|
||||
recipient_user_id > 0
|
||||
AND sender_user_id = 777000
|
||||
AND gift_id > 0
|
||||
AND char_length(command_key) BETWEEN 1 AND 256
|
||||
AND octet_length(request_fingerprint) = 32
|
||||
)
|
||||
);
|
||||
|
|
@ -51,7 +51,7 @@ This document describes every setting loaded by `internal/config`. Defaults and
|
|||
| Setting | Type / code default | Description and constraints |
|
||||
|---|---|---|
|
||||
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug listener. Empty disables it. Keep loopback-only; use an SSH tunnel for production profiling. |
|
||||
| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. |
|
||||
| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. `setWebhook` accepts any valid `http://` or `https://` host/IP and port in `1..65535`. |
|
||||
| `TELESRV_ADMIN_API_ADDR` | nullable address / empty | In-process Admin write API listener. Empty disables it; production should bind loopback. |
|
||||
| `TELESRV_ADMIN_API_TOKEN` | secret string / empty | Admin API bearer token. Required when the Admin API is enabled and must match the Admin UI token configuration. |
|
||||
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | Standalone `cmd/telesrv-admin` listen address. |
|
||||
|
|
@ -60,9 +60,320 @@ This document describes every setting loaded by `internal/config`. Defaults and
|
|||
| `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. |
|
||||
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. |
|
||||
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. |
|
||||
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
|
||||
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
|
||||
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
|
||||
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
|
||||
| `TELESRV_SCAM_WARNING` | string / empty | Overrides the profile warning injected into `getFullUser`/`getFullChannel` About for SCAM-flagged peers. Empty keeps the built-in per-peer-type English default. Non-destructive: the stored bio/description is never overwritten and the warning is re-applied from the flag on every read. Clients cannot localize server-provided text. |
|
||||
| `TELESRV_FAKE_WARNING` | string / empty | Same as `TELESRV_SCAM_WARNING`, for FAKE-flagged peers. |
|
||||
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | When enabled, permits any valid HTTP issuer, BotFather Web origin, redirect URI, and native HTTP callback, without loopback, subnet, or port restrictions. When disabled, those Web URLs still require HTTPS. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | JOSE private-key ring generated by `cmd/telegramloginkeygen`; active plus retiring public keys are published through JWKS. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | AES-256-GCM envelope-key ring for recoverable, one-time authorization codes. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | Deployment pepper for HMAC-SHA-256 client-secret hashes. The file must contain a base64 encoding of exactly 32 random bytes. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | Pending authorization lifetime; bounded to `1m..15m`. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | One-time code lifetime; bounded to `30s..10m`. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | Signed ID-token lifetime; bounded to `1m..24h`. Retiring signing keys must cover this window. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | comma-separated CIDRs / empty | Only requests whose direct peer is in this list may supply `Forwarded`/`X-Forwarded-*` client metadata. The documented nginx deployment uses `127.0.0.1/32,::1/128`. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | Retention after terminal request/code/revocation state; bounded to `1h..90d`. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | Retention worker interval; bounded to `10s..1h`. |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | Maximum rows per retention pass; bounded to `1..1000`. |
|
||||
|
||||
### 3.1 Bot API webhook troubleshooting
|
||||
|
||||
Start by separating the three addresses below. Never use the webhook receiver domain as the Bot
|
||||
API endpoint unless an explicit reverse-proxy route maps that domain to telesrv:
|
||||
|
||||
| Name | Setting/source | Direction and purpose |
|
||||
|---|---|---|
|
||||
| Bot API listener | telesrv `TELESRV_BOT_API_ADDR` | The telesrv bind address; empty disables the gateway. `0.0.0.0` is valid only for binding and is not a client request target. |
|
||||
| Bot API base URL | the bot application's `TELEGRAM_API_URL` or equivalent | A client-reachable address for telesrv, for example `http://172.17.0.1:8088`. Method URLs are `<base>/bot<TOKEN>/<method>` and file URLs are `<base>/file/bot<TOKEN>/<file_path>`. |
|
||||
| Webhook receiver URL | the bot application's `WEBHOOK_URL + WEBHOOK_PATH`, registered by `setWebhook` | The target to which telesrv actively POSTs updates, for example `https://bot.example.com/webhook`. It is not the Bot API base URL. |
|
||||
|
||||
The network direction is different too: polling is `bot application -> telesrv Bot API`, while
|
||||
webhook delivery is `telesrv -> bot application webhook receiver`. Working polling proves only the
|
||||
first path. It does not prove webhook DNS, outbound TCP, TLS, reverse proxy, or Docker hairpin
|
||||
connectivity.
|
||||
|
||||
#### 1. Query the authoritative webhook state from the Bot API
|
||||
|
||||
Run this inside the bot application container with its actual Bot API base URL. Do not expand and
|
||||
paste the token into chat, tickets, or screenshots:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST \
|
||||
"${TELEGRAM_API_URL%/}/bot${BOT_TOKEN}/getWebhookInfo" | jq
|
||||
```
|
||||
|
||||
If the application uses a differently named variable, replace `TELEGRAM_API_URL` with the
|
||||
**client-reachable address** corresponding to `TELESRV_BOT_API_ADDR`. For example, if telesrv binds
|
||||
`0.0.0.0:8088`, a container on the same host might use `http://172.17.0.1:8088`; it must not request
|
||||
`http://0.0.0.0:8088`.
|
||||
|
||||
Interpret the result as follows:
|
||||
|
||||
| Result | Conclusion and next step |
|
||||
|---|---|
|
||||
| Empty `url` | No webhook is registered on this telesrv instance. Verify that the application uses this Bot API base URL and that startup `setWebhook` succeeded. |
|
||||
| Increasing `pending_update_count` | Updates reached the telesrv durable queue but are not being delivered successfully. Inspect `last_error_message`. |
|
||||
| HTTP `401`/`403` in `last_error_message` | The receiver is reachable, but its webhook secret differs or an authentication layer rejected the request. |
|
||||
| `dial tcp ... i/o timeout` | telesrv cannot connect to the target IP/port. Check outbound firewall rules, Docker networking, loopback/hairpin NAT, and security groups. |
|
||||
| `connection refused` | The address is reachable, but nothing listens on that port or the port mapping/reverse-proxy upstream is wrong. |
|
||||
| DNS/`no such host` | The webhook hostname cannot be resolved from the telesrv runtime environment. |
|
||||
| TLS/`x509` error | The certificate chain, hostname, SNI, or container CA trust is wrong. HTTPS uses the system trust store. |
|
||||
| Target type absent from `allowed_updates` | Newly produced updates of that type are not queued. A normal `/start` requires at least `message`. |
|
||||
| Pending reaches zero but the app does not react | telesrv received a 2xx response. Inspect the receiver's internal queue, workers, dispatcher, and handlers. |
|
||||
|
||||
`getWebhookInfo` reports telesrv's persisted delivery facts. An application `/health` endpoint only
|
||||
proves that its receiver route and workers started; it cannot replace this check.
|
||||
|
||||
#### 2. Validate the receiver with the correct header
|
||||
|
||||
The Telegram webhook secret is distinct from the Bot token, OIDC Client Secret, and other API
|
||||
keys. The receiver validates `X-Telegram-Bot-Api-Secret-Token`, not `Authorization: Bearer`:
|
||||
|
||||
```sh
|
||||
curl -i -X POST "${WEBHOOK_URL%/}${WEBHOOK_PATH}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Telegram-Bot-Api-Secret-Token: ${WEBHOOK_SECRET_TOKEN}" \
|
||||
-d '{"update_id":2147483000}'
|
||||
```
|
||||
|
||||
Expect an HTTP 2xx response. `401 invalid_secret_token` proves that the request reached the
|
||||
application but the header was absent or did not match. Recreate/restart the application after
|
||||
editing `.env`; changing the file alone neither updates the secret already registered in telesrv
|
||||
nor the receiver process's startup-time secret.
|
||||
|
||||
#### 3. Test from the actual telesrv network namespace
|
||||
|
||||
A browser or official Telegram reaching the public webhook proves only public inbound
|
||||
connectivity. Repeat the test from the host, container, or network namespace that actually runs
|
||||
telesrv:
|
||||
|
||||
```sh
|
||||
docker exec <telesrv-container> sh -lc \
|
||||
'getent hosts bot.example.com; curl -vk --connect-timeout 10 https://bot.example.com/health/unified'
|
||||
```
|
||||
|
||||
If public clients work but this returns `dial tcp ...:443: i/o timeout`, a same-host public-IP
|
||||
hairpin failure is a common cause. Prefer split DNS or a container host mapping so the public
|
||||
hostname resolves to the reverse proxy's internal entry point inside the telesrv container while
|
||||
preserving the hostname, HTTPS SNI, and certificate validation. If the reverse proxy publishes
|
||||
443 on the Docker host, test first with:
|
||||
|
||||
```sh
|
||||
curl -vk --resolve bot.example.com:443:172.17.0.1 \
|
||||
https://bot.example.com/health/unified
|
||||
```
|
||||
|
||||
After that succeeds, a deployment may use a network-appropriate Compose entry such as:
|
||||
|
||||
```yaml
|
||||
extra_hosts:
|
||||
- "bot.example.com:host-gateway"
|
||||
```
|
||||
|
||||
Other fixes include attaching telesrv to the reverse proxy's Docker network, allowing the Docker
|
||||
subnet to reach host port 443, or correcting cloud security-group/NAT hairpin rules. telesrv allows
|
||||
an internal HTTP receiver, but use one only on a controlled shared network and only when the
|
||||
application's `WEBHOOK_URL` is not also its public OIDC, payment, or media callback base. Do not
|
||||
blindly replace a global public URL with an internal address to mask a routing problem.
|
||||
|
||||
#### 4. Close the loop after the fix
|
||||
|
||||
1. Restart the bot application so it calls `setWebhook` again with the current URL, secret, and
|
||||
`allowed_updates`.
|
||||
2. Send a new `/start` or press a callback button.
|
||||
3. Call `getWebhookInfo` again. `pending_update_count` should fall to `0`, with no new
|
||||
`last_error_date`.
|
||||
4. Inspect telesrv Warning logs for `bot api webhook delivery failed`. The record contains
|
||||
`bot_user_id`, `retry_in`, and the failure reason, but must not contain the webhook URL, Bot
|
||||
token, or secret.
|
||||
5. Confirm that the receiver recorded and processed the `update_id`. Delivery is at-least-once, so
|
||||
the application must safely handle duplicate updates caused by retries.
|
||||
|
||||
Immediately rotate any Bot token, webhook secret, OIDC Client Secret, API key, or database
|
||||
password exposed in shell history, chat, or screenshots. Keep only redacted diagnostics in support
|
||||
material.
|
||||
|
||||
### 3.2 Complete Telegram Login / OIDC setup
|
||||
|
||||
#### 1. Generate `data/telegram-login` once
|
||||
|
||||
Run this from the `telesrv` repository root:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
|
||||
Get-ChildItem .\data\telegram-login
|
||||
```
|
||||
|
||||
The same command works on Linux; restrict the generated directory afterward:
|
||||
|
||||
```bash
|
||||
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
|
||||
chmod 0700 data/telegram-login
|
||||
chmod 0600 data/telegram-login/*
|
||||
```
|
||||
|
||||
Initialization creates the following private files. It never prints key material and refuses to
|
||||
overwrite an existing `signing-keys.json`, `code-keys.json`, or `client-secret-pepper`:
|
||||
|
||||
- `signing-keys.json` plus three `signing-*.pem` files: the manifest and private keys for RS256,
|
||||
ES256, and EdDSA ID-token signatures;
|
||||
- `code-keys.json`: the AES-256-GCM envelope-key ring for one-time authorization codes;
|
||||
- `client-secret-pepper`: a 32-byte deployment pepper used to store and verify OIDC Client Secret
|
||||
digests.
|
||||
|
||||
The repository ignores `data/*` by default. Never put this directory in Git, release archives,
|
||||
logs, or ordinary backups. All instances must mount the same protected files and restart together
|
||||
after rotation. Losing the pepper invalidates existing Client Secret verification. Losing a signing
|
||||
key that is still in its publication window invalidates otherwise-live ID tokens against JWKS.
|
||||
|
||||
#### 2. Configure and start the Provider
|
||||
|
||||
This example exposes OIDC directly at `http://192.0.2.25:2401`; replace it with the server address
|
||||
that clients can actually reach. Bind `0.0.0.0:2401` for direct LAN/public access, or keep
|
||||
`127.0.0.1:2401` when an on-host reverse proxy is the only caller:
|
||||
|
||||
```env
|
||||
TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401
|
||||
TELESRV_PUBLIC_APP_SCHEME=telesrv
|
||||
# Optional for multi-server clients: TELESRV_PUBLIC_APP_LINK_BASE=owpg://example.com
|
||||
|
||||
TELESRV_TELEGRAM_LOGIN_ENABLE=true
|
||||
TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401
|
||||
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true
|
||||
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
|
||||
```
|
||||
|
||||
For HTTPS, set the issuer and public base to the exact HTTPS origin and leave
|
||||
`TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`. The issuer becomes the token `iss` and the root of all
|
||||
discovery endpoints, so its scheme, host, and port must exactly match the address used by relying
|
||||
parties. Start or restart `telesrv`, then verify the public endpoints:
|
||||
|
||||
```powershell
|
||||
curl.exe http://192.0.2.25:2401/.well-known/openid-configuration
|
||||
curl.exe http://192.0.2.25:2401/.well-known/jwks.json
|
||||
curl.exe -I http://192.0.2.25:2401/js/telegram-login.js
|
||||
```
|
||||
|
||||
The discovery `issuer` must equal the configured value, and its `authorization_endpoint`,
|
||||
`token_endpoint`, and `jwks_uri` must be reachable by the relying party. A reverse proxy must pass
|
||||
through `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/auth`, `/auth/status`,
|
||||
`/token`, `/crossapp`, `/inapp`, `/telegram-login.js`, and `/js/telegram-login.js` unchanged.
|
||||
|
||||
#### 3. Create an OIDC Client with the local `@BotFather`
|
||||
|
||||
Create a bot with `/newbot` or select an existing bot. In the local `@BotFather`, run `/setlogin`
|
||||
and choose that bot. Initial setup returns:
|
||||
|
||||
- `Client ID`: the bot user ID as a decimal string;
|
||||
- `Client Secret`: shown once, separate from the Bot API token, and meant to be saved immediately
|
||||
in a secret manager.
|
||||
|
||||
After selecting a bot once, BotFather keeps that configuration session active; there is no need to
|
||||
repeat `/setlogin` and the bot username for every change. Send commands one at a time or paste them
|
||||
as separate lines in one message (up to 32 lines per message). This example runs the relying party
|
||||
at `http://192.0.2.30:3000`:
|
||||
|
||||
```text
|
||||
add origin http://192.0.2.30:3000
|
||||
add redirect http://192.0.2.30:3000/oauth/callback
|
||||
algorithm RS256
|
||||
enable
|
||||
```
|
||||
|
||||
Send `/done` after the changes succeed. BotFather closes the session and returns the final
|
||||
configuration summary. Every successful change takes effect immediately, so `/cancel` only closes
|
||||
the session and does not roll back changes. If a multi-line message fails partway through,
|
||||
BotFather identifies the applied lines, the failed line, and the later lines that were skipped,
|
||||
then keeps the selected bot active for a corrected command.
|
||||
|
||||
An `origin` is an exact Web origin without a path, query, or fragment; it authorizes the JS SDK,
|
||||
popup CORS, and legacy `login_url`. A `redirect` is the exact full URI that receives an
|
||||
Authorization Code. Wildcards and prefix matching are not supported. Use `/logininfo` to inspect
|
||||
status and registrations; use `/setlogin` to add/remove URLs, change the algorithm, or disable the
|
||||
client; use `/resetloginsecret` to rotate the Client Secret. Available algorithms are RS256,
|
||||
ES256, EdDSA, and ES256K only when its build/key ring is present. EdDSA and ES256K accept only the
|
||||
`openid` scope.
|
||||
|
||||
#### 4. Integrate a relying party with standard OIDC
|
||||
|
||||
Start by loading:
|
||||
|
||||
```text
|
||||
http://192.0.2.25:2401/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
The standard flow is Authorization Code with PKCE S256:
|
||||
|
||||
1. Generate random `state`, `nonce`, and PKCE `code_verifier`; derive the S256 `code_challenge`.
|
||||
2. Open the discovery `authorization_endpoint` with `client_id`, the exact `redirect_uri`,
|
||||
`response_type=code`, a `scope` containing `openid`, `state`, `nonce`, `code_challenge`, and
|
||||
`code_challenge_method=S256`.
|
||||
3. After the user approves in TDesktop/Android, verify `state` at the relying-party callback and
|
||||
read the one-time code.
|
||||
4. Server-side, POST `grant_type=authorization_code`, the code, the same `redirect_uri`, and
|
||||
`code_verifier` to the discovery `token_endpoint`. Confidential clients authenticate with HTTP
|
||||
Basic or `client_secret_post`.
|
||||
5. Verify the ID-token signature with the discovery `jwks_uri`, then strictly validate `iss`,
|
||||
`aud`, `exp`, `nonce`, and a non-empty `sub`. Decoding without signature verification is not
|
||||
sufficient.
|
||||
|
||||
Supported scopes are `openid`, `profile`, `phone`, and `telegram:bot_access`. The provider does not
|
||||
currently expose UserInfo, refresh tokens, or an introspection endpoint. Browser applications may
|
||||
load `<issuer>/js/telegram-login.js` for the local JS SDK. A Client Secret must remain server-side.
|
||||
|
||||
#### 5. Verify the complete path with the Bedolaga demo
|
||||
|
||||
Install the demo dependencies:
|
||||
|
||||
```powershell
|
||||
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
|
||||
-r .\cmd\bots\bedolagaformat\requirements.txt
|
||||
```
|
||||
|
||||
Put the Client ID/Secret from step 3 and the same bot's Bot API token only in process environment:
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_BOT_TOKEN = "<bot_id>:<bot_api_secret>"
|
||||
$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081"
|
||||
$env:TELESRV_BOT_LOGIN_DEMO = "1"
|
||||
$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<Client ID returned by BotFather>"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<one-time OIDC Client Secret>"
|
||||
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000"
|
||||
$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000"
|
||||
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
|
||||
```
|
||||
|
||||
The BotFather origin must equal `TELESRV_BOT_LOGIN_PUBLIC_URL`, and the redirect must equal
|
||||
`<TELESRV_BOT_LOGIN_PUBLIC_URL>/oauth/callback`. Send `/logindemo` to the bot. The first button
|
||||
tests Bot API `login_url` plus the HMAC callback; the second page tests the local JS SDK popup and
|
||||
Authorization Code + PKCE/JWKS. Omitting the Client Secret leaves JS popup verification available
|
||||
but explicitly disables the server-side code flow.
|
||||
|
||||
#### 6. Rotate keys
|
||||
|
||||
When rotating a signing key, retain the old public key for at least the configured ID-token TTL
|
||||
plus ten minutes. Restart all instances together after the operation:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 `
|
||||
-id-token-ttl 1h -publish-for 2h -dir data/telegram-login
|
||||
go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login
|
||||
```
|
||||
|
||||
Run `rotate-signing` separately for RS256, ES256, or EdDSA. `rotate-code` retains old code keys and
|
||||
adds a new active key. Do not edit manifests or PEM files manually, and never generate divergent
|
||||
key rings independently on different instances.
|
||||
|
||||
## 4. PostgreSQL, Redis, files, and seed data
|
||||
|
||||
|
|
@ -95,7 +406,7 @@ The language-pack file manifest is authoritative. To add a language, place `data
|
|||
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. |
|
||||
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. |
|
||||
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. |
|
||||
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Must use `http`/`https` and contain no userinfo. |
|
||||
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Any valid `http://` or `https://` host/IP and port is accepted; userinfo is rejected. |
|
||||
| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / empty | Optional HMAC-SHA256 signing secret; enables `X-Telesrv-Signature` when non-empty. |
|
||||
| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP timeout; must be positive when Webhook delivery is enabled. |
|
||||
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification. SMTP settings are required only when the email provider is `smtp`. |
|
||||
|
|
@ -207,6 +518,19 @@ The following fallback keys are accepted from the **process environment only**.
|
|||
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | Idempotent lazy starting Stars balance for all accounts; `0` disables automatic grant. |
|
||||
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | Expired-premium cleanup/push interval. Read paths derive expiry independently. |
|
||||
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | Maximum expired premium rows processed per sweep. |
|
||||
| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Local Star Gift offer/auction lifecycle sweep interval; no blockchain connection is made. |
|
||||
| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | Maximum offer/auction/outbox work claimed per lifecycle sweep. |
|
||||
| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | Nanoton granted idempotently on a user's first access to the internal telesrv TON ledger; `0` disables it. This is not an on-chain asset. |
|
||||
| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | Stars charged for a collectible transfer; `0` enables the free-transfer RPC. |
|
||||
| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | Stars charged to remove a collectible's original sender/message details. |
|
||||
| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | Minimum Stars offer snapshotted for user-owned collectibles; `0` disables the offer entry point. |
|
||||
| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Seller share in Stars sales, in permille; the remainder is recorded as platform commission. |
|
||||
| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | Seller share in internal-TON sales, in permille; this affects only the local ledger. |
|
||||
| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | Delay snapshotted into `can_export_at` when a collectible is issued. |
|
||||
| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | Delay snapshotted into `can_transfer_at`. |
|
||||
| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | Delay snapshotted into `can_resell_at`. |
|
||||
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | Delay snapshotted into `can_craft_at`. |
|
||||
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | Per-input local craft success contribution, capped at 1000 permille. |
|
||||
|
||||
## 11. Private calls, group calls, TURN, SFU, and livestream
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|
||||
|---|---|---|
|
||||
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug 监听;空值关闭。生产必须保持 loopback,通过 SSH 隧道抓取。 |
|
||||
| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。 |
|
||||
| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。`setWebhook` 接受任意合法 `http://` 或 `https://` 主机/IP 与 `1..65535` 端口。 |
|
||||
| `TELESRV_ADMIN_API_ADDR` | nullable address / 空 | 进程内 Admin 写 API;空值关闭,生产应只监听 loopback。 |
|
||||
| `TELESRV_ADMIN_API_TOKEN` | secret string / 空 | Admin API bearer token;启用 Admin API 时必须显式配置,并与 Admin UI 使用的 token 一致。 |
|
||||
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | 独立 `cmd/telesrv-admin` 监听地址。 |
|
||||
|
|
@ -60,9 +60,302 @@
|
|||
| `TELESRV_ADMIN_SESSION_KEY` | secret string / 空 | 加密/签名 Admin UI session cookie;生产至少使用 32 字节随机值,修改会使已有会话失效。 |
|
||||
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | 客户端可见的公开链接根地址;允许 path,禁止 credentials、query、fragment。本地例:`http://127.0.0.1:2401`。 |
|
||||
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme,必须与 patched 客户端注册值一致;禁止 `tg`、`http`、`https`。 |
|
||||
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / 空 | 多服务客户端可选的 host-based 根,例如 `owpg://example.com`。配置后生成 `owpg://example.com/oauth`、`owpg://example.com/<username>` 等;只允许精确 `<custom-scheme>://<host>`,禁止端口、path、query、fragment。`TELESRV_PUBLIC_APP_SCHEME` 仍作为旧链接输入兼容。 |
|
||||
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 |
|
||||
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名;trim 后非空、无控制字符、最多 64 个 Unicode 字符。 |
|
||||
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 |
|
||||
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist/collectible gift 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | 在 `TELESRV_PUBLIC_LINK_WEB_ADDR` 上挂载自建 Telegram Login/OIDC Provider;启用时必须同时配置该 listener 与下列全部密钥文件。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | 绝对 origin URL / `TELESRV_PUBLIC_BASE_URL` | discovery 与 token 使用的精确公开 issuer;默认必须 HTTPS,禁止 path、credentials、query、fragment。开启下一项后可直接配置任意 HTTP 域名/IP。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | 开启后允许任意合法 HTTP issuer、BotFather Web origin、redirect URI 和 native HTTP callback,不限制为 loopback,也不限制 IP 网段或端口。关闭时这些 Web URL 仍必须 HTTPS。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | 由 `cmd/telegramloginkeygen` 生成的 JOSE 私钥环;JWKS 会发布 active 和仍在退役窗口内的公钥。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | 用于可恢复一次性 authorization code 的 AES-256-GCM envelope key ring。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | HMAC-SHA-256 Client Secret 摘要的部署 pepper 文件,内容必须是恰好 32 个随机字节的 base64 编码。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | pending authorization 生命周期,限定 `1m..15m`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | 一次性 code 生命周期,限定 `30s..10m`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | ID token 生命周期,限定 `1m..24h`;退役签名公钥必须覆盖该窗口。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | 逗号分隔 CIDR / 空 | 只有直连 peer 落在该列表时才信任 `Forwarded`/`X-Forwarded-*` 客户端元数据;文档中的单机 nginx 部署使用 `127.0.0.1/32,::1/128`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | terminal request/code/revocation 后的保留期,限定 `1h..90d`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | retention worker 周期,限定 `10s..1h`。 |
|
||||
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | 每轮最大清理行数,限定 `1..1000`。 |
|
||||
|
||||
### 3.1 Bot API webhook 故障排查
|
||||
|
||||
先区分三个地址,禁止把 webhook 接收域名当成 Bot API 地址:
|
||||
|
||||
| 名称 | 配置/来源 | 方向与用途 |
|
||||
|---|---|---|
|
||||
| Bot API listener | telesrv 的 `TELESRV_BOT_API_ADDR` | telesrv 的监听地址;空值表示关闭。`0.0.0.0` 只能用于 bind,不能作为客户端请求目标。 |
|
||||
| Bot API base URL | bot 应用的 `TELEGRAM_API_URL` 等配置 | bot 应用访问 telesrv 的可达地址,例如 `http://172.17.0.1:8088`。方法地址为 `<base>/bot<TOKEN>/<method>`,文件地址为 `<base>/file/bot<TOKEN>/<file_path>`。 |
|
||||
| Webhook receiver URL | bot 应用的 `WEBHOOK_URL + WEBHOOK_PATH`,经 `setWebhook` 登记 | telesrv 主动 POST update 的目标,例如 `https://bot.example.com/webhook`。它不是 Bot API base URL。 |
|
||||
|
||||
网络方向也不同:polling 是 `bot 应用 -> telesrv Bot API`,webhook 是
|
||||
`telesrv -> bot 应用 webhook receiver`。因此 polling 正常只能证明前一条路径可达,
|
||||
不能证明 webhook 的 DNS、出站 TCP、TLS、反向代理或 Docker hairpin 路径正常。
|
||||
|
||||
#### 1. 从 Bot API 查询真实 webhook 状态
|
||||
|
||||
应在 bot 应用容器中使用它实际配置的 Bot API base URL;不要把 token 展开后粘贴到
|
||||
聊天、工单或截图:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST \
|
||||
"${TELEGRAM_API_URL%/}/bot${BOT_TOKEN}/getWebhookInfo" | jq
|
||||
```
|
||||
|
||||
若没有 `TELEGRAM_API_URL` 这个变量,就把它替换成与
|
||||
`TELESRV_BOT_API_ADDR` 对应的**客户端可达地址**。例如 telesrv 监听
|
||||
`0.0.0.0:8088`,同宿主 Docker 容器可能使用 `http://172.17.0.1:8088`;不要请求
|
||||
`http://0.0.0.0:8088`。
|
||||
|
||||
按下表判读响应:
|
||||
|
||||
| 结果 | 结论与下一步 |
|
||||
|---|---|
|
||||
| `url` 为空 | webhook 没有登记到这台 telesrv;检查 bot 应用是否确实使用该 Bot API base URL,以及启动时 `setWebhook` 是否成功。 |
|
||||
| `pending_update_count` 增长 | update 已进入 telesrv durable queue,但没有成功交付;继续看 `last_error_message`。 |
|
||||
| `last_error_message` 为 HTTP `401`/`403` | 接收端已可达,但 webhook secret 不一致或请求被认证层拒绝。 |
|
||||
| `dial tcp ... i/o timeout` | telesrv 到目标 IP/端口的连接超时;检查出站防火墙、Docker 网络、回环 NAT/hairpin 和安全组。 |
|
||||
| `connection refused` | 目标地址可达,但相应端口没有监听或端口映射/反代 upstream 错误。 |
|
||||
| DNS/`no such host` | telesrv 所在运行环境无法解析 webhook hostname。 |
|
||||
| TLS/`x509` 错误 | 证书链、hostname、SNI 或容器 CA trust 有问题。HTTPS 使用系统信任链。 |
|
||||
| `allowed_updates` 不含目标类型 | 新产生的该类型 update 不会入队;普通 `/start` 至少需要 `message`。 |
|
||||
| pending 归零但应用无响应 | telesrv 已收到 2xx;转查接收应用内部 queue、worker、dispatcher 和 handler 日志。 |
|
||||
|
||||
`getWebhookInfo` 查询的是 telesrv 持久化的交付事实;应用自己的 `/health` 只能证明
|
||||
接收路由和 worker 已启动,不能代替这一步。
|
||||
|
||||
#### 2. 用正确请求头验证接收端
|
||||
|
||||
Telegram webhook secret 与 Bot token、OIDC Client Secret、API key 都是不同凭据。
|
||||
接收端校验的标准请求头是 `X-Telegram-Bot-Api-Secret-Token`,不是
|
||||
`Authorization: Bearer`:
|
||||
|
||||
```sh
|
||||
curl -i -X POST "${WEBHOOK_URL%/}${WEBHOOK_PATH}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Telegram-Bot-Api-Secret-Token: ${WEBHOOK_SECRET_TOKEN}" \
|
||||
-d '{"update_id":2147483000}'
|
||||
```
|
||||
|
||||
预期为 HTTP 2xx。`401 invalid_secret_token` 表示请求已经到达应用,但 header 缺失或
|
||||
值不匹配。编辑 `.env` 后必须重建/重启读取该配置的应用;只修改磁盘文件不会更新
|
||||
已经登记到 telesrv 的 secret,也不会更新接收进程启动时捕获的 secret。
|
||||
|
||||
#### 3. 从 telesrv 的实际网络命名空间测试
|
||||
|
||||
浏览器或官方 Telegram 能访问公网 webhook,只能证明公网入站正常。必须从实际运行
|
||||
telesrv 的宿主机、容器或 network namespace 再测一次:
|
||||
|
||||
```sh
|
||||
docker exec <telesrv-container> sh -lc \
|
||||
'getent hosts bot.example.com; curl -vk --connect-timeout 10 https://bot.example.com/health/unified'
|
||||
```
|
||||
|
||||
如果公网客户端正常而这里 `dial tcp ...:443: i/o timeout`,常见原因是同机公网 IP
|
||||
回环失败。优先使用 split DNS 或容器 host mapping,让公网 hostname 在 telesrv 容器
|
||||
内解析到反向代理的内部入口,同时保留原 hostname、HTTPS SNI 和证书校验。例如反代
|
||||
的 443 已发布到 Docker 宿主机时,可先验证:
|
||||
|
||||
```sh
|
||||
curl -vk --resolve bot.example.com:443:172.17.0.1 \
|
||||
https://bot.example.com/health/unified
|
||||
```
|
||||
|
||||
验证通过后,可在 telesrv Compose 中使用与实际网络匹配的配置:
|
||||
|
||||
```yaml
|
||||
extra_hosts:
|
||||
- "bot.example.com:host-gateway"
|
||||
```
|
||||
|
||||
其它可选修复包括:把 telesrv 接入反向代理所在 Docker network、为 Docker subnet
|
||||
放行宿主机 443,或修正云安全组/NAT hairpin。telesrv 允许登记内部 HTTP receiver,
|
||||
但只有在两端共享受控内网且调用方的 `WEBHOOK_URL` 不同时承担 OIDC、支付或公开媒体
|
||||
回调时才应使用;不要为绕过网络问题盲目把应用的全局公开 URL 改成内部地址。
|
||||
|
||||
#### 4. 修复后的闭环验证
|
||||
|
||||
1. 重新启动 bot 应用,让它用当前 URL、secret 和 `allowed_updates` 再次调用
|
||||
`setWebhook`。
|
||||
2. 发送一条新的 `/start` 或点击 callback 按钮。
|
||||
3. 再次调用 `getWebhookInfo`;`pending_update_count` 应下降到 `0`,且不再出现新的
|
||||
`last_error_date`。
|
||||
4. 检查 telesrv Warning 日志中的 `bot api webhook delivery failed`。日志包含
|
||||
`bot_user_id`、`retry_in` 和失败原因,但不得记录 webhook URL、Bot token 或 secret。
|
||||
5. 检查接收应用是否记录并处理该 `update_id`。webhook 是 at-least-once,应用必须能
|
||||
安全处理失败重试带来的重复 update。
|
||||
|
||||
若凭据曾出现在命令历史、聊天或截图中,立即轮换 Bot token、webhook secret、OIDC
|
||||
Client Secret 及同屏暴露的其它 API key/数据库密码;排查资料只保留脱敏结果。
|
||||
|
||||
### 3.2 Telegram Login / OIDC 完整启用流程
|
||||
|
||||
#### 1. 一次性生成 `data/telegram-login`
|
||||
|
||||
在 `telesrv` 仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
|
||||
Get-ChildItem .\data\telegram-login
|
||||
```
|
||||
|
||||
Linux 部署也可使用同一命令;生成后应限制目录权限:
|
||||
|
||||
```bash
|
||||
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
|
||||
chmod 0700 data/telegram-login
|
||||
chmod 0600 data/telegram-login/*
|
||||
```
|
||||
|
||||
初始化会生成以下私密文件,命令不会把密钥内容输出到终端,并会拒绝覆盖已经存在的
|
||||
`signing-keys.json`、`code-keys.json` 或 `client-secret-pepper`:
|
||||
|
||||
- `signing-keys.json` 和三个 `signing-*.pem`:RS256、ES256、EdDSA ID token 签名私钥及清单;
|
||||
- `code-keys.json`:一次性 authorization code 使用的 AES-256-GCM envelope key ring;
|
||||
- `client-secret-pepper`:保存和校验 OIDC Client Secret 摘要时使用的 32 字节部署 pepper。
|
||||
|
||||
`data/*` 默认已被仓库 `.gitignore` 排除。不要把该目录放入 Git、发布压缩包、日志或
|
||||
普通备份;多实例必须挂载同一份受保护的文件,并在轮换后一起重启。丢失 pepper 会让
|
||||
现有 Client Secret 无法验证,丢失仍在发布窗口内的签名私钥会让尚未过期的 ID token
|
||||
无法继续通过 JWKS 验证。
|
||||
|
||||
#### 2. 配置并启动 Provider
|
||||
|
||||
以下示例直接通过 `http://192.0.2.25:2401` 对外提供 OIDC;请替换成客户端实际可达的
|
||||
服务器 IP。直接监听局域网/公网网卡时使用 `0.0.0.0:2401`,仅由同机反向代理转发时
|
||||
应改回 `127.0.0.1:2401`:
|
||||
|
||||
```env
|
||||
TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401
|
||||
TELESRV_PUBLIC_APP_SCHEME=telesrv
|
||||
# 多服务客户端可选:TELESRV_PUBLIC_APP_LINK_BASE=owpg://example.com
|
||||
|
||||
TELESRV_TELEGRAM_LOGIN_ENABLE=true
|
||||
TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401
|
||||
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true
|
||||
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
|
||||
```
|
||||
|
||||
使用 HTTPS 时,把 `TELESRV_TELEGRAM_LOGIN_ISSUER` 和公开根地址改成精确 HTTPS
|
||||
origin,并保持 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`。issuer 是 token 的 `iss`
|
||||
以及 discovery 中所有端点的根地址,scheme、host 和 port 必须与依赖方访问的地址完全
|
||||
一致。启动或重启 `telesrv` 后,先验证公开端点:
|
||||
|
||||
```powershell
|
||||
curl.exe http://192.0.2.25:2401/.well-known/openid-configuration
|
||||
curl.exe http://192.0.2.25:2401/.well-known/jwks.json
|
||||
curl.exe -I http://192.0.2.25:2401/js/telegram-login.js
|
||||
```
|
||||
|
||||
discovery 返回的 `issuer` 必须等于配置值,`authorization_endpoint`、`token_endpoint`
|
||||
和 `jwks_uri` 必须可从依赖方访问。使用反向代理时需原样转发
|
||||
`/.well-known/openid-configuration`、`/.well-known/jwks.json`、`/auth`、`/auth/status`、
|
||||
`/token`、`/crossapp`、`/inapp`、`/telegram-login.js` 和 `/js/telegram-login.js`。
|
||||
|
||||
#### 3. 用本服 `@BotFather` 创建 OIDC Client
|
||||
|
||||
先用 `/newbot` 创建或选择已有 bot,然后在本服 `@BotFather` 中执行 `/setlogin` 并选择
|
||||
该 bot。首次配置会返回:
|
||||
|
||||
- `Client ID`:bot user ID 的十进制字符串;
|
||||
- `Client Secret`:只显示一次,与 Bot API token 不同,必须立即保存到密钥管理系统。
|
||||
|
||||
选择一次 bot 后会持续停留在它的配置会话中,无需为每项修改重复 `/setlogin` 和 bot
|
||||
username。可以逐条发送,也可以像下面这样在一条消息中粘贴多行命令(每条消息最多
|
||||
32 行)。下面假设依赖方页面运行在 `http://192.0.2.30:3000`:
|
||||
|
||||
```text
|
||||
add origin http://192.0.2.30:3000
|
||||
add redirect http://192.0.2.30:3000/oauth/callback
|
||||
algorithm RS256
|
||||
enable
|
||||
```
|
||||
|
||||
全部修改成功后发送 `/done`,BotFather 会退出配置会话并返回最终配置摘要。各条修改会
|
||||
立即生效;`/cancel` 只关闭当前会话,不会回滚已经成功的修改。多行消息若中途失败,
|
||||
BotFather 会明确列出已应用项、失败行以及未执行的后续行,并保留当前 bot 选择供修正
|
||||
后继续操作。
|
||||
|
||||
`origin` 只能是无 path/query/fragment 的精确 Web origin,用于 JS SDK、popup CORS 和
|
||||
legacy `login_url`;`redirect` 是 Authorization Code Flow 返回 code 的精确完整 URI。
|
||||
不支持 wildcard 或 prefix 匹配。用 `/logininfo` 检查状态和登记值;用 `/setlogin`
|
||||
增删 URL、切换签名算法或 disable;用 `/resetloginsecret` 轮换 Client Secret。可用的
|
||||
签名算法为 RS256、ES256、EdDSA,以及仅在对应构建和 key ring 已提供时可选的 ES256K;
|
||||
EdDSA/ES256K 只允许 `openid` scope。
|
||||
|
||||
#### 4. 依赖方接入标准 OIDC
|
||||
|
||||
依赖方应首先读取:
|
||||
|
||||
```text
|
||||
http://192.0.2.25:2401/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
标准流程为 Authorization Code + PKCE S256:
|
||||
|
||||
1. 生成随机 `state`、`nonce` 和 PKCE `code_verifier`,计算 S256 `code_challenge`;
|
||||
2. 浏览器打开 discovery 中的 `authorization_endpoint`,携带 `client_id`、精确
|
||||
`redirect_uri`、`response_type=code`、包含 `openid` 的 `scope`、`state`、`nonce`、
|
||||
`code_challenge` 和 `code_challenge_method=S256`;
|
||||
3. 用户在 TDesktop/Android 中确认后,依赖方 callback 校验 `state` 并取得一次性 code;
|
||||
4. 服务端向 discovery 中的 `token_endpoint` POST `grant_type=authorization_code`、code、
|
||||
同一 `redirect_uri` 和 `code_verifier`,机密 client 使用 HTTP Basic 或
|
||||
`client_secret_post` 提交 Client Secret;
|
||||
5. 用 discovery 的 `jwks_uri` 验证 ID token 签名,并严格校验 `iss`、`aud`、`exp`、
|
||||
`nonce` 和非空 `sub`。不要只解码而不验签。
|
||||
|
||||
支持的 scope 为 `openid`、`profile`、`phone`、`telegram:bot_access`。当前不提供
|
||||
UserInfo、refresh token 或 introspection endpoint。浏览器前端可以加载
|
||||
`<issuer>/js/telegram-login.js` 使用本地 JS SDK;Client Secret 只能留在服务端。
|
||||
|
||||
#### 5. 使用 Bedolaga demo 验证完整链路
|
||||
|
||||
安装 demo 依赖:
|
||||
|
||||
```powershell
|
||||
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
|
||||
-r .\cmd\bots\bedolagaformat\requirements.txt
|
||||
```
|
||||
|
||||
将第 3 步得到的 Client ID/Secret 和同一个 Bot API token 仅放入进程环境:
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_BOT_TOKEN = "<bot_id>:<bot_api_secret>"
|
||||
$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081"
|
||||
$env:TELESRV_BOT_LOGIN_DEMO = "1"
|
||||
$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<BotFather 返回的 Client ID>"
|
||||
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<只显示一次的 OIDC Client Secret>"
|
||||
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000"
|
||||
$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000"
|
||||
|
||||
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
|
||||
```
|
||||
|
||||
确保 BotFather 登记的 origin 等于 `TELESRV_BOT_LOGIN_PUBLIC_URL`,redirect 等于
|
||||
`<TELESRV_BOT_LOGIN_PUBLIC_URL>/oauth/callback`。在客户端向 bot 发送 `/logindemo`:第一颗
|
||||
按钮验证 Bot API `login_url` 和 HMAC 回调,第二颗按钮页面分别验证本地 JS SDK popup
|
||||
以及 Authorization Code + PKCE/JWKS。省略 Client Secret 时只能验证 JS popup,服务端
|
||||
code flow 会明确禁用。
|
||||
|
||||
#### 6. 密钥轮换
|
||||
|
||||
签名 key 轮换时,旧公钥发布窗口必须至少覆盖配置的 ID token TTL 再加 10 分钟;操作
|
||||
完成后所有实例一起重启:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 `
|
||||
-id-token-ttl 1h -publish-for 2h -dir data/telegram-login
|
||||
go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login
|
||||
```
|
||||
|
||||
`rotate-signing` 可分别用于 RS256、ES256、EdDSA;`rotate-code` 保留旧 code key 并新增
|
||||
active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别生成不一致的 key ring。
|
||||
|
||||
## 4. PostgreSQL、Redis、文件与 seed
|
||||
|
||||
|
|
@ -95,7 +388,7 @@
|
|||
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 |
|
||||
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息,Webhook 只是附加渠道。 |
|
||||
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp` 或 `webhook`。已有账号的登录邮箱码会先同码镜像到 777000;邮箱 setup/change 仍只走 provider。 |
|
||||
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。只允许 `http`/`https` 且不得含 userinfo。 |
|
||||
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。允许任意合法 `http://` 或 `https://` 主机/IP 与端口,不得含 userinfo。 |
|
||||
| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / 空 | 可选 HMAC-SHA256 签名密钥;非空时发送 `X-Telesrv-Signature`。 |
|
||||
| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP 请求超时,启用 Webhook 时必须为正数。 |
|
||||
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码;email provider 为 `smtp` 时要求 SMTP 配置,`webhook` 时不依赖 SMTP。 |
|
||||
|
|
@ -207,6 +500,19 @@
|
|||
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | 对所有账号幂等惰性授予的 Stars 起始余额;`0` 关闭自动赠送。 |
|
||||
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | 过期 Premium 清理/推送周期;读取路径独立即时派生到期状态。 |
|
||||
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | 单次 sweep 最大处理行数。 |
|
||||
| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Star Gift 报价/竞拍本地生命周期清扫周期;不会连接区块链。 |
|
||||
| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | 单次礼物生命周期清扫最多处理的报价、竞拍与 outbox 工作量。 |
|
||||
| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | 每个用户首次访问 telesrv 内部 TON 账本时幂等授予的 nanoton;`0` 关闭赠送。它不是链上资产。 |
|
||||
| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | collectible 转赠费用;设为 `0` 时使用免费转赠 RPC。 |
|
||||
| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | 移除 collectible 原始发送者/附言信息所需 Stars。 |
|
||||
| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | collectible 签发时固化的用户持有礼物最低 Stars 报价;`0` 不开放报价入口。 |
|
||||
| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Stars 成交时卖方实收比例(千分比);差额作为平台佣金写入成交记录。 |
|
||||
| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | 内部 TON 成交时卖方实收比例(千分比);只影响本地账本。 |
|
||||
| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | collectible 签发时固化到 `can_export_at` 的等待期。 |
|
||||
| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | 签发时固化到 `can_transfer_at` 的等待期。 |
|
||||
| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | 签发时固化到 `can_resell_at` 的等待期。 |
|
||||
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | 签发时固化到 `can_craft_at` 的等待期;可 Craft 礼物即使为 `0s` 也写升级时间这一正数能力边界,0 只表示不具备 Craft 能力或已终结。 |
|
||||
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | 每份输入礼物贡献的本地合成成功概率,累计上限 1000‰。 |
|
||||
|
||||
## 11. 私聊通话、群通话、TURN、SFU 与直播
|
||||
|
||||
|
|
|
|||
106
docs/otp-delivery.md
Normal file
106
docs/otp-delivery.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# OTP delivery providers
|
||||
|
||||
`telesrv` owns OTP generation, storage, attempt limits, expiry, verification,
|
||||
and consumption. A delivery provider receives an already-issued code and must
|
||||
only deliver it. It must not generate a replacement code or decide whether an
|
||||
authentication attempt succeeds.
|
||||
|
||||
## Routing
|
||||
|
||||
- `TELESRV_PHONE_CODE_DELIVERY_PROVIDER=development` preserves the local fixed
|
||||
code. `webhook` generates random SMS codes for login, registration,
|
||||
login-email reset fallback, and phone changes.
|
||||
- `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=smtp` preserves direct SMTP delivery.
|
||||
`webhook` handles login-email, login-email setup, and login-email change.
|
||||
- One Webhook endpoint may handle both channels. `channel` and `purpose` in the
|
||||
request select the downstream template/provider.
|
||||
|
||||
For an existing account, external delivery is additive: `auth.sendCode` and
|
||||
`auth.resendCode` first commit the same code as a durable incoming message from
|
||||
777000, then invoke the configured SMS or login-email provider. A provider
|
||||
cannot replace or invalidate that App-code. A new phone and email setup/change
|
||||
have no existing login dialog to receive the code, so those flows use only the
|
||||
configured external provider.
|
||||
|
||||
## Webhook v1 request
|
||||
|
||||
`telesrv` sends one `POST` request and does not follow redirects:
|
||||
|
||||
```http
|
||||
POST /v1/otp/deliveries HTTP/1.1
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
Idempotency-Key: otp_0193f0...
|
||||
X-Telesrv-Timestamp: 1784275200
|
||||
X-Telesrv-Signature: sha256=...
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"delivery_id": "otp_0193f0...",
|
||||
"purpose": "login_email",
|
||||
"channel": "email",
|
||||
"recipient": "alice@example.test",
|
||||
"code": "482913",
|
||||
"expires_at": "2026-07-17T16:05:00Z",
|
||||
"expires_in": 299,
|
||||
"locale": "zh-CN"
|
||||
}
|
||||
```
|
||||
|
||||
Current purpose values are `login_email`, `login_sms`,
|
||||
`login_email_setup`, `login_email_change`, and `change_phone`. Current channel
|
||||
values are `email` and `sms`.
|
||||
|
||||
`delivery_id` is an opaque idempotency key. Replays of the same ID must not
|
||||
send a second message. A resend that creates a new code has a new delivery ID.
|
||||
|
||||
When `TELESRV_OTP_WEBHOOK_SECRET` is non-empty, the signature is lowercase hex
|
||||
HMAC-SHA256 over:
|
||||
|
||||
```text
|
||||
<X-Telesrv-Timestamp>.<exact raw JSON request body>
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
An accepted request returns any 2xx response with this JSON shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true,
|
||||
"message_id": "provider-message-123"
|
||||
}
|
||||
```
|
||||
|
||||
`204 No Content` is also accepted. Other 2xx responses must explicitly contain
|
||||
`"accepted": true`; a missing or malformed acknowledgement is treated as an
|
||||
unknown outcome because the provider may already have sent the code.
|
||||
|
||||
An explicit rejection may use either a non-2xx status or `accepted: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": false,
|
||||
"error_code": "RECIPIENT_INVALID",
|
||||
"retryable": false
|
||||
}
|
||||
```
|
||||
|
||||
The response body is capped at 64 KiB. For a flow without a durable 777000
|
||||
fallback, an explicit rejection invalidates only the code attempt that
|
||||
triggered that request. A transport error or invalid successful acknowledgement
|
||||
preserves the code and returns its hash because the provider may already have
|
||||
sent it. For an existing-account login, any provider failure is reported but
|
||||
does not fail the RPC or invalidate the code: the durable 777000 copy remains
|
||||
the authoritative fallback.
|
||||
|
||||
Webhook logs contain the opaque delivery ID, purpose, channel, status, and
|
||||
transport error only. The code and recipient are not logged.
|
||||
|
||||
A runnable standard-library receiver is available at
|
||||
[`cmd/otpwebhook-example`](../cmd/otpwebhook-example/README.md). It includes
|
||||
signature/timestamp validation, request limits, idempotency, health checking,
|
||||
and graceful shutdown. Its delivery function is intentionally a no-op adapter
|
||||
and must be replaced with the user's email/SMS API call.
|
||||
46
go.mod
46
go.mod
|
|
@ -3,33 +3,35 @@ module telesrv
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/fxamacker/cbor/v2 v2.8.0
|
||||
github.com/go-faster/errors v0.7.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/gotd/ige v0.2.2
|
||||
github.com/gotd/ige v0.3.0
|
||||
github.com/gotd/log/logzap v0.1.1
|
||||
github.com/iamxvbaba/td v1.1.3
|
||||
github.com/iamxvbaba/td v1.1.5
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/pion/datachannel v1.6.2
|
||||
github.com/pion/dtls/v3 v3.1.4
|
||||
github.com/pion/ice/v4 v4.2.7
|
||||
github.com/pion/dtls/v3 v3.1.5
|
||||
github.com/pion/ice/v4 v4.3.0
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/rtcp v1.2.17
|
||||
github.com/pion/rtp v1.10.3
|
||||
github.com/pion/sctp v1.10.3
|
||||
github.com/pion/rtp v1.10.4
|
||||
github.com/pion/sctp v1.11.0
|
||||
github.com/pion/srtp/v3 v3.0.12
|
||||
github.com/pion/transport/v4 v4.0.2
|
||||
github.com/pion/turn/v5 v5.0.10
|
||||
github.com/pion/turn/v5 v5.0.12
|
||||
github.com/redis/go-redis/v9 v9.20.0
|
||||
github.com/yutopp/go-rtmp v0.0.7
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.31.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/text v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -43,6 +45,7 @@ require (
|
|||
github.com/go-faster/jx v1.2.0 // indirect
|
||||
github.com/go-faster/xor v1.0.0 // indirect
|
||||
github.com/go-faster/yaml v0.4.6 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotd/log v0.1.0 // indirect
|
||||
github.com/gotd/neo v0.1.5 // indirect
|
||||
|
|
@ -51,11 +54,17 @@ require (
|
|||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.1 // indirect
|
||||
github.com/ogen-go/ogen v1.22.0 // indirect
|
||||
github.com/ogen-go/ogen v1.23.0 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
|
|
@ -64,19 +73,20 @@ require (
|
|||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.4 // indirect
|
||||
github.com/yutopp/go-amf0 v0.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
|
|
|||
93
go.sum
93
go.sum
|
|
@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
|
|
@ -48,7 +50,6 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY
|
|||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
|
||||
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
|
||||
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
|
||||
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
|
||||
|
|
@ -57,6 +58,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
|
|
@ -65,8 +68,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
||||
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||
github.com/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU=
|
||||
github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac=
|
||||
github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU=
|
||||
github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs=
|
||||
github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ=
|
||||
|
|
@ -78,8 +81,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||
github.com/iamxvbaba/td v1.1.3 h1:g9GE2jZVB1U1N8HuaRy707EzYfcsEuXIC//wI23VYsg=
|
||||
github.com/iamxvbaba/td v1.1.3/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04=
|
||||
github.com/iamxvbaba/td v1.1.5 h1:fy6wWQIMzKKOXBwBRcnDaLFhiHyQazJt9eMOrdhl22E=
|
||||
github.com/iamxvbaba/td v1.1.5/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
|
@ -90,18 +93,32 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
|
|
@ -112,18 +129,18 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
|||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4=
|
||||
github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g=
|
||||
github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo=
|
||||
github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
|
||||
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
|
||||
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
|
||||
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/ice/v4 v4.3.0 h1:X8l4s9zV2HeTKX33nulWAFXAEo5KhIVzOsY62/3t/LM=
|
||||
github.com/pion/ice/v4 v4.3.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
|
|
@ -132,10 +149,10 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
|||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
|
||||
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
|
||||
github.com/pion/rtp v1.10.3 h1:r5nJQdtM9Dc4ZYxtTcPPz7PIFArKJIf/DMlIUxU7+1c=
|
||||
github.com/pion/rtp v1.10.3/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
|
||||
github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/rtp v1.10.4 h1:4sCUwUd35Nllcpyp8V7lRgb4DV/ulHJaRTjbrkAcpQ4=
|
||||
github.com/pion/rtp v1.10.4/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.11.0 h1:sAxv9Qp3uIcaF5wu1XntwshtnW93CEuxhpkYzSbnfMs=
|
||||
github.com/pion/sctp v1.11.0/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
|
||||
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
|
|
@ -144,8 +161,8 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY
|
|||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
|
||||
github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
|
||||
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
|
||||
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
|
@ -175,14 +192,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yutopp/go-amf0 v0.1.0 h1:a3UeBZG7nRF0zfvmPn2iAfNo1RGzUpHz1VyJD2oGrik=
|
||||
github.com/yutopp/go-amf0 v0.1.0/go.mod h1:QzDOBr9RV6sQh6E5GFEJROZbU0iQKijORBmprkb3FIk=
|
||||
github.com/yutopp/go-flv v0.3.1/go.mod h1:pAlHPSVRMv5aCUKmGOS/dZn/ooTgnc09qOPmiUNMubs=
|
||||
|
|
@ -210,30 +229,30 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
|||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -19,10 +21,12 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
notifier := &fakeAccountFreezeNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: repo,
|
||||
Restrictions: restrictions,
|
||||
Now: fixedNow,
|
||||
Commands: repo,
|
||||
Restrictions: restrictions,
|
||||
FreezeNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
|
||||
|
|
@ -53,6 +57,9 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
|
||||
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
|
||||
}
|
||||
if len(notifier.items) != 1 || notifier.items[0].UserID != 1001 || !notifier.items[0].Frozen || notifier.items[0].Version != 1 {
|
||||
t.Fatalf("freeze notifications = %+v, want one versioned frozen state", notifier.items)
|
||||
}
|
||||
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
|
|
@ -68,6 +75,95 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
if !again.AlreadyExecuted || restrictions.setCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls)
|
||||
}
|
||||
if len(notifier.items) != 1 {
|
||||
t.Fatalf("idempotent replay emitted duplicate notification: %+v", notifier.items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBotReturnsTokenOnceWithoutPersistingCredential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
bots := &fakeBotService{token: "test-one-time-bot-credential"}
|
||||
svc := NewService(Dependencies{Commands: repo, Bots: bots, Now: fixedNow})
|
||||
req := CreateBotRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "create-bot-once", Actor: "ops", Reason: "requested"},
|
||||
OwnerUserID: 1001,
|
||||
Name: "Audit Safe Bot",
|
||||
Username: "audit_safe_bot",
|
||||
}
|
||||
|
||||
first, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot: %v", err)
|
||||
}
|
||||
if first.Details["token"] != bots.token || bots.createCalls != 1 {
|
||||
t.Fatalf("first result=%+v createCalls=%d", first, bots.createCalls)
|
||||
}
|
||||
stored := repo.items[req.CommandID].ResultJSON
|
||||
if bytes.Contains(stored, []byte(bots.token)) || bytes.Contains(stored, []byte(`"token"`)) {
|
||||
t.Fatalf("persisted admin result contains bot credential: %s", stored)
|
||||
}
|
||||
|
||||
replay, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot replay: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || bots.createCalls != 1 {
|
||||
t.Fatalf("replay=%+v createCalls=%d", replay, bots.createCalls)
|
||||
}
|
||||
if _, leaked := replay.Details["token"]; leaked {
|
||||
t.Fatalf("replayed command exposed one-time bot token: %+v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{1001: {ID: 1001}}}
|
||||
channels := &fakeChannelsService{channels: map[int64]domain.Channel{2001: {
|
||||
ID: 2001, Megagroup: true,
|
||||
}}}
|
||||
svc := NewService(Dependencies{Commands: repo, Users: users, Channels: channels, Now: fixedNow})
|
||||
meta := CommandMeta{CommandID: "invalid-user-flags", Actor: "ops", Reason: "test"}
|
||||
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: 1001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetUserFlags error=%v", err)
|
||||
}
|
||||
meta.CommandID = "invalid-channel-flags"
|
||||
if _, err := svc.SetChannelFlags(ctx, SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: 2001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetChannelFlags error=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 || users.users[1001].Scam || users.users[1001].Fake ||
|
||||
channels.channels[2001].Scam || channels.channels[2001].Fake {
|
||||
t.Fatalf("invalid moderation state reached command/store boundary: commands=%d user=%+v channel=%+v",
|
||||
len(repo.items), users.users[1001], channels.channels[2001])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
|
||||
now := fixedNow()
|
||||
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
|
||||
1001: {
|
||||
UserID: 1001, Frozen: true, Version: 2, Since: now,
|
||||
Until: now.Add(time.Hour), AppealURL: "https://appeals.example.test/1001",
|
||||
},
|
||||
1002: {UserID: 1002, Frozen: false, Version: 4},
|
||||
}}}
|
||||
svc := NewService(Dependencies{Restrictions: store, Now: fixedNow})
|
||||
|
||||
got, err := svc.AccountFreezes(context.Background(), []int64{1001, 1001, 0, 1002})
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFreezes: %v", err)
|
||||
}
|
||||
if len(store.requests) != 1 || !reflect.DeepEqual(store.requests[0], []int64{1001, 1002}) {
|
||||
t.Fatalf("batch requests = %v, want one deduplicated request", store.requests)
|
||||
}
|
||||
if len(got) != 1 || !got[1001].Frozen || got[1001].Version != 2 {
|
||||
t.Fatalf("AccountFreezes = %+v, want active user 1001 only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
|
||||
|
|
@ -472,6 +568,22 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
type fakeBotService struct {
|
||||
token string
|
||||
createCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) {
|
||||
f.createCalls++
|
||||
return domain.User{ID: 2001, FirstName: name, Username: username, Bot: true}, f.token, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotService) DeleteBot(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
f.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
|
|
@ -490,11 +602,37 @@ func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.Acco
|
|||
f.items = map[int64]domain.AccountFreeze{}
|
||||
}
|
||||
f.setCalls++
|
||||
r.Version = f.items[r.UserID].Version + 1
|
||||
r.UpdatedAt = fixedNow()
|
||||
f.items[r.UserID] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type fakeBatchRestrictionStore struct {
|
||||
fakeRestrictionStore
|
||||
requests [][]int64
|
||||
}
|
||||
|
||||
func (f *fakeBatchRestrictionStore) GetAccountFreezes(_ context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
f.requests = append(f.requests, append([]int64(nil), userIDs...))
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range userIDs {
|
||||
if freeze, ok := f.items[id]; ok && freeze.Frozen {
|
||||
out[id] = freeze
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeAccountFreezeNotifier struct {
|
||||
items []domain.AccountFreeze
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezeNotifier) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error {
|
||||
f.items = append(f.items, freeze)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMessagesService struct {
|
||||
byID []domain.Message
|
||||
deleteCalls int
|
||||
|
|
@ -621,6 +759,60 @@ func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Scam = scam
|
||||
u.Fake = fake
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetSupport(_ context.Context, userID int64, support bool) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Support = support
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Username = username
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if forProfile {
|
||||
u.ProfileColor = color
|
||||
} else {
|
||||
u.Color = color
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakeStarsService struct {
|
||||
balances map[int64]domain.StarsBalance
|
||||
creditCalls int
|
||||
|
|
@ -695,6 +887,66 @@ func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, ve
|
|||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) SetScamFake(_ context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.Scam = scam
|
||||
ch.Fake = fake
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetSettings(_ context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if patch.Gigagroup != nil {
|
||||
ch.Gigagroup = *patch.Gigagroup
|
||||
}
|
||||
if patch.SlowmodeSeconds != nil {
|
||||
ch.SlowmodeSeconds = *patch.SlowmodeSeconds
|
||||
}
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetUsername(_ context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.Username = username
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetColor(_ context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if forProfile {
|
||||
ch.ProfileColor = color
|
||||
} else {
|
||||
ch.Color = color
|
||||
}
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetEmojiStatus(_ context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.EmojiStatus = status
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type fakeChannelNotifier struct {
|
||||
channels []int64
|
||||
}
|
||||
|
|
@ -739,9 +991,18 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
|||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := PublishStarGiftCollectiblesRequest{
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{{Name: "Ruby", RarityPermille: 1000, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")}},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{{Name: "Stars", RarityPermille: 1000, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")}},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 1000}},
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileKey: "model-1", FileName: "sapphire.lottie", Data: []byte("model-1")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")},
|
||||
{Name: "Moons", RarityPermille: 500, FileKey: "pattern-1", FileName: "moons.tgs", Data: []byte("pattern-1")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
|
||||
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
|
|
@ -755,6 +1016,101 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesRejectsUnsafeClientPreviewPool(t *testing.T) {
|
||||
valid := func() PublishStarGiftCollectiblesRequest {
|
||||
return PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "unsafe-pool", Actor: "ops", Reason: "regression", DryRun: true},
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileName: "ruby.lottie", Data: []byte("ruby")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileName: "sapphire.lottie", Data: []byte("sapphire")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileName: "stars.lottie", Data: []byte("stars")},
|
||||
{Name: "Moons", RarityPermille: 500, FileName: "moons.lottie", Data: []byte("moons")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
}
|
||||
tests := map[string]func(*PublishStarGiftCollectiblesRequest){
|
||||
"single model": func(req *PublishStarGiftCollectiblesRequest) { req.Models = req.Models[:1] },
|
||||
"single pattern": func(req *PublishStarGiftCollectiblesRequest) { req.Patterns = req.Patterns[:1] },
|
||||
"single backdrop": func(req *PublishStarGiftCollectiblesRequest) { req.Backdrops = req.Backdrops[:1] },
|
||||
"duplicate backdrop id": func(req *PublishStarGiftCollectiblesRequest) {
|
||||
req.Backdrops[1].BackdropID = req.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := valid()
|
||||
mutate(&req)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: &fakeGiftsService{}, Now: fixedNow})
|
||||
if _, err := svc.PublishStarGiftCollectibles(context.Background(), req); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
|
||||
permille := 922
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x42, 32),
|
||||
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
|
||||
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
|
||||
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
|
||||
},
|
||||
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
|
||||
{Name: "Regular Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 5, FileName: "regular-two.tgs", SHA256: strings.Repeat("e", 64), Data: []byte("regular-two")}},
|
||||
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}},
|
||||
{Name: "Pattern Two", DocumentID: 6, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 6, FileName: "pattern-two.tgs", SHA256: strings.Repeat("f", 64), Data: []byte("pattern-two")}},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "White", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
|
||||
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
|
||||
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
|
||||
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
|
||||
result, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
models := gifts.lastBundle.Collectible.Models
|
||||
if len(models) != 3 || !models[2].Crafted || models[2].RarityKind != domain.StarGiftRarityLegendary || models[2].RarityPermille != 0 ||
|
||||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
|
||||
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
|
||||
}
|
||||
catalog := gifts.lastBundle.Catalog
|
||||
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
|
||||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
|
||||
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
|
||||
}
|
||||
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
|
||||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
|
||||
t.Fatalf("official provenance was not preserved: %+v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
|
|
@ -855,6 +1211,67 @@ func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
|
||||
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
|
||||
document := func(id int64, name string) officialgifts.Document {
|
||||
raw := []byte(lottie)
|
||||
sum := sha256.Sum256(raw)
|
||||
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
|
||||
}
|
||||
permille := 1000
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x24, sha256.Size),
|
||||
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
|
||||
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
|
||||
},
|
||||
BaseDocument: document(1, "gift.json"),
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(2, "model.json")},
|
||||
{Name: "Model Two", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(4, "model-two.json")},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(3, "pattern.json")},
|
||||
{Name: "Pattern Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(5, "pattern-two.json")},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "Backdrop Two", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
|
||||
})
|
||||
req := ImportOfficialStarGiftRequest{
|
||||
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
|
||||
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
|
||||
}
|
||||
result, err := svc.ImportOfficialStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
|
||||
}
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
|
||||
if err != nil || !ok || len(preview.Models) != 2 || len(preview.Patterns) != 2 {
|
||||
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
|
||||
}
|
||||
model := preview.Models[0].Document
|
||||
pattern := preview.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
|
||||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
type adminGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *adminGiftBlob) Name() string { return "localfs" }
|
||||
|
|
@ -868,11 +1285,41 @@ func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
|||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func bytesOf(value byte, count int) []byte {
|
||||
out := make([]byte, count)
|
||||
for i := range out {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
|
||||
|
||||
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
|
||||
if giftID != f.bundle.Gift.ID {
|
||||
return officialgifts.Bundle{}, officialgifts.ErrNotFound
|
||||
}
|
||||
out := f.bundle
|
||||
if !include {
|
||||
out.Collectible = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeGiftsService struct {
|
||||
createCalls int
|
||||
lastBundle domain.StarGiftCatalogBundleWrite
|
||||
}
|
||||
|
||||
func (f *fakeGiftsService) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if id <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
return domain.StarGiftAnimation{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,19 @@ type Service interface {
|
|||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
|
||||
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
|
||||
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
|
||||
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
|
||||
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
|
||||
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
||||
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
|
||||
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
|
||||
SetChannelUsername(ctx context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error)
|
||||
SetChannelColor(ctx context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error)
|
||||
SetChannelEmojiStatus(ctx context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error)
|
||||
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
|
||||
|
|
@ -55,7 +67,9 @@ type Service interface {
|
|||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
||||
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||
GiveGift(ctx context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error)
|
||||
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
}
|
||||
|
|
@ -111,8 +125,20 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
|
||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
|
||||
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
|
||||
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
|
||||
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
||||
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
||||
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
|
||||
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
|
||||
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
|
||||
mux.HandleFunc("POST /v1/channels/set-username", s.authenticated(s.handleSetChannelUsername))
|
||||
mux.HandleFunc("POST /v1/channels/set-color", s.authenticated(s.handleSetChannelColor))
|
||||
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
|
||||
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
|
||||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
|
||||
|
|
@ -135,7 +161,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
||||
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
||||
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
||||
mux.HandleFunc("POST /v1/gifts/give", s.authenticated(s.handleGiveGift))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
|
||||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
return mux
|
||||
|
|
@ -219,6 +247,114 @@ func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserFlags(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserFlagsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserFlags(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelFlags(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelFlagsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelFlags(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetSupport(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetSupportRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetSupport(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserColorRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserColor(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserEmojiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserEmojiStatusRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserEmojiStatus(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelSettingsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelSettings(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelColor(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelColorRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelColor(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelEmojiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelEmojiStatusRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelEmojiStatus(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.CreateBotRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.CreateBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.DeleteBotRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.DeleteBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeSessionsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -605,6 +741,15 @@ func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.R
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleGiveGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GiveGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GiveGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
|
|
@ -626,6 +771,27 @@ func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request)
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || documentID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.EmojiAnimation(r.Context(), documentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "emoji animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
|
|
|
|||
|
|
@ -121,11 +121,14 @@ func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
|
|||
func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":1000,"sort_order":0,"file_key":"model-0"}],"patterns":[{"name":"Stars","rarity_permille":1000,"sort_order":0,"file_key":"pattern-0"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":1000,"sort_order":0}]}`
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":500,"sort_order":0,"file_key":"model-0"},{"name":"Sapphire","rarity_permille":500,"sort_order":1,"file_key":"model-1"}],"patterns":[{"name":"Stars","rarity_permille":500,"sort_order":0,"file_key":"pattern-0"},{"name":"Moons","rarity_permille":500,"sort_order":1,"file_key":"pattern-1"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":500,"sort_order":0},{"name":"Day","backdrop_id":2,"center_color":11189196,"edge_color":7833753,"pattern_color":14544639,"text_color":1118481,"rarity_permille":500,"sort_order":1}]}`
|
||||
if err := writer.WriteField("metadata", metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, name := range map[string]string{"model-0": "ruby.lottie", "pattern-0": "stars.tgs"} {
|
||||
for key, name := range map[string]string{
|
||||
"model-0": "ruby.lottie", "model-1": "sapphire.lottie",
|
||||
"pattern-0": "stars.tgs", "pattern-1": "moons.tgs",
|
||||
} {
|
||||
part, err := writer.CreateFormFile(key, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -147,8 +150,8 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
|||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 1 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 1 {
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 2 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 2 || svc.req.Backdrops[1].BackdropID != 2 {
|
||||
t.Fatalf("decoded collectible request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
|
@ -231,6 +234,58 @@ func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVer
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelFlags(_ context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetSupport(_ context.Context, req admin.SetSupportRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GiveGift(_ context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserEmojiStatus(_ context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelSettings(_ context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelUsername(_ context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelColor(_ context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelEmojiStatus(_ context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
|
@ -331,6 +386,10 @@ func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, erro
|
|||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -33,6 +34,10 @@ const (
|
|||
botFatherCmdSetInlineFB = "setinlinefeedback"
|
||||
botFatherCmdSetJoinGroups = "setjoingroups"
|
||||
botFatherCmdSetPrivacy = "setprivacy"
|
||||
botFatherCmdSetLogin = "setlogin"
|
||||
botFatherCmdLoginInfo = "logininfo"
|
||||
botFatherCmdResetLogin = "resetloginsecret"
|
||||
botFatherCmdDone = "done"
|
||||
|
||||
botFatherStepName = "name"
|
||||
botFatherStepUsername = "username"
|
||||
|
|
@ -41,6 +46,8 @@ const (
|
|||
|
||||
botFatherDraftBotID = "bot_id"
|
||||
botFatherDraftBotUsername = "bot_username"
|
||||
|
||||
maxTelegramLoginCommandsPerMessage = 32
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
|
|
@ -60,6 +67,10 @@ You can control me by sending these commands:
|
|||
/setinlinefeedback - change inline feedback settings
|
||||
/setjoingroups - toggle whether a bot can join groups
|
||||
/setprivacy - toggle a bot's group privacy mode
|
||||
/setlogin - configure Telegram Login allowed URLs and signing
|
||||
/logininfo - show a bot's Telegram Login configuration
|
||||
/resetloginsecret - rotate a bot's OIDC Client Secret
|
||||
/done - finish the active Telegram Login configuration
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
|
|
@ -169,12 +180,13 @@ func (s *Service) botReplyRandomID() int64 {
|
|||
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
|
||||
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
|
||||
var botFatherGlobalCommands = map[string]bool{
|
||||
"start": true, "help": true, "cancel": true,
|
||||
"start": true, "help": true, "cancel": true, botFatherCmdDone: true,
|
||||
botFatherCmdNewBot: true, "mybots": true,
|
||||
botFatherCmdToken: true, botFatherCmdRevoke: true,
|
||||
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
|
||||
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
|
||||
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
|
||||
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
|
|
@ -231,6 +243,9 @@ var pickerPrompts = map[string]string{
|
|||
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
|
||||
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
|
||||
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
|
||||
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
|
||||
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
|
||||
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
|
||||
}
|
||||
|
||||
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
|
||||
|
|
@ -277,7 +292,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: botFatherHelpText}
|
||||
case "cancel":
|
||||
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
|
|
@ -289,7 +304,12 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
|
||||
return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
|
||||
}
|
||||
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
|
||||
case botFatherCmdDone:
|
||||
return s.finishTelegramLoginConfiguration(ctx, userID)
|
||||
case botFatherCmdNewBot:
|
||||
count, err := s.bots.CountBotsByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -322,7 +342,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
|
||||
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
|
||||
return s.startBotPicker(ctx, userID, cmd)
|
||||
case botFatherCmdSetInlineFB:
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
|
|
@ -351,6 +372,8 @@ func valuePrompt(cmd, username string) string {
|
|||
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
|
||||
case botFatherCmdSetPrivacy:
|
||||
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
|
||||
case botFatherCmdSetLogin:
|
||||
return telegramLoginConfigurationPrompt(username)
|
||||
default:
|
||||
return "Send the new value, or /cancel."
|
||||
}
|
||||
|
|
@ -445,6 +468,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
|
|||
}
|
||||
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdLoginInfo:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
|
||||
}
|
||||
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
|
||||
case botFatherCmdResetLogin:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
|
||||
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
|
||||
case botFatherCmdSetLogin:
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
state.Step = botFatherStepValue
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
|
||||
state.Draft[botFatherDraftBotUsername] = chosen.Username
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
prompt := telegramLoginConfigurationPrompt(chosen.Username)
|
||||
if !created {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
|
||||
}
|
||||
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
|
||||
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
|
||||
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
|
|
@ -507,6 +585,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
case botFatherCmdSetLogin:
|
||||
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
|
|
@ -583,6 +663,266 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
|
|||
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationPrompt(username string) string {
|
||||
return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
|
||||
|
||||
add origin https://example.com
|
||||
add redirect https://example.com/auth/callback
|
||||
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
|
||||
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
|
||||
remove origin https://example.com
|
||||
remove redirect https://example.com/auth/callback
|
||||
remove app 12
|
||||
algorithm RS256|ES256|EdDSA|ES256K
|
||||
enable
|
||||
disable
|
||||
|
||||
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationContinuePrompt(username string) string {
|
||||
return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
|
||||
}
|
||||
|
||||
func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
|
||||
return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
|
||||
}
|
||||
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
|
||||
username := state.Draft[botFatherDraftBotUsername]
|
||||
if botID == 0 || username == "" {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
|
||||
}
|
||||
owns, err := s.OwnsBot(ctx, userID, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !owns {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "That bot is no longer available."}
|
||||
}
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !configured {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
|
||||
}
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
|
||||
s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
|
||||
}
|
||||
|
||||
func (s *Service) handleTelegramLoginConfigurationInput(
|
||||
ctx context.Context,
|
||||
state domain.BotChatState,
|
||||
botID int64,
|
||||
username string,
|
||||
text string,
|
||||
) botReply {
|
||||
if strings.EqualFold(strings.TrimSpace(text), "done") {
|
||||
return s.finishTelegramLoginConfiguration(ctx, state.UserID)
|
||||
}
|
||||
lines := make([]string, 0, 4)
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if line := strings.TrimSpace(raw); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
if len(lines) > maxTelegramLoginCommandsPerMessage {
|
||||
return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
|
||||
}
|
||||
|
||||
applied := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
|
||||
if err != nil {
|
||||
if len(lines) == 1 {
|
||||
if reply.Text == "" {
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
failure := reply.Text
|
||||
if failure == "" {
|
||||
failure = "Something went wrong on my side. Please try that line again later."
|
||||
}
|
||||
var out strings.Builder
|
||||
if len(applied) > 0 {
|
||||
fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
|
||||
if i+1 < len(lines) {
|
||||
fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
|
||||
}
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
if len(lines) == 1 {
|
||||
out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
|
||||
} else {
|
||||
fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
|
||||
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
|
||||
status := "disabled"
|
||||
if configuration.Client.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
|
||||
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
|
||||
if len(configuration.AllowedURLs) == 0 {
|
||||
out.WriteString("\nAllowed URLs: none")
|
||||
} else {
|
||||
out.WriteString("\nAllowed URLs:")
|
||||
for _, allowed := range configuration.AllowedURLs {
|
||||
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
|
||||
}
|
||||
}
|
||||
if len(configuration.NativeApps) > 0 {
|
||||
out.WriteString("\nNative apps:")
|
||||
for _, app := range configuration.NativeApps {
|
||||
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "origin":
|
||||
return domain.TelegramLoginAllowedWebOrigin, true
|
||||
case "redirect":
|
||||
return domain.TelegramLoginAllowedRedirectURI, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "RS256":
|
||||
return domain.TelegramLoginSigningRS256, true
|
||||
case "ES256":
|
||||
return domain.TelegramLoginSigningES256, true
|
||||
case "EDDSA":
|
||||
return domain.TelegramLoginSigningEdDSA, true
|
||||
case "ES256K":
|
||||
return domain.TelegramLoginSigningES256K, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(text))
|
||||
if len(fields) == 1 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "enable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
|
||||
case "disable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
|
||||
}
|
||||
}
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
|
||||
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
|
||||
}
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
|
||||
}
|
||||
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
|
||||
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
|
||||
kind, ok := telegramLoginAllowedURLKind(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(fields[0], "add") {
|
||||
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
|
||||
}
|
||||
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
|
||||
platform := domain.TelegramLoginNativeIOS
|
||||
if strings.EqualFold(fields[1], "android") {
|
||||
platform = domain.TelegramLoginNativeAndroid
|
||||
}
|
||||
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
|
||||
if err != nil {
|
||||
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
|
||||
}
|
||||
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
|
||||
appID, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil || appID <= 0 {
|
||||
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
|
||||
if err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
|
||||
}
|
||||
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
167
internal/app/bots/botfather_login_test.go
Normal file
167
internal/app/bots/botfather_login_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
|
||||
t.Helper()
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 1
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 2
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
|
||||
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1090")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") {
|
||||
t.Fatalf("/setlogin reply = %q", reply)
|
||||
}
|
||||
created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot")
|
||||
if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") {
|
||||
t.Fatalf("create login reply = %q", created)
|
||||
}
|
||||
secretMarker := "only be shown once:\n"
|
||||
secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0]
|
||||
if len(secret) < 32 {
|
||||
t.Fatalf("client secret is unexpectedly short: %q", secret)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add origin reply = %q", reply)
|
||||
}
|
||||
state, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID)
|
||||
if err != nil || !found || state.Step != botFatherStepValue || state.Draft[botFatherDraftBotID] != strconv.FormatInt(bot.ID, 10) {
|
||||
t.Fatalf("state after first command = %+v, found=%v err=%v", state, found, err)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add redirect reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
|
||||
t.Fatalf("algorithm reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add iOS app reply = %q", reply)
|
||||
}
|
||||
fingerprint := strings.Repeat("A", 64)
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add Android app reply = %q", reply)
|
||||
}
|
||||
done := sendToBotFather(t, svc, messages, owner, "/done")
|
||||
if !strings.Contains(done, "Finished configuring") || !strings.Contains(done, "Signing algorithm: ES256") {
|
||||
t.Fatalf("/done reply = %q", done)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /done: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/logininfo")
|
||||
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
|
||||
if !strings.Contains(info, want) {
|
||||
t.Fatalf("login info = %q, missing %q", info, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(info, secret) {
|
||||
t.Fatal("/logininfo leaked the one-time client secret")
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/resetloginsecret")
|
||||
rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) {
|
||||
t.Fatalf("rotate reply = %q", rotated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginBatchAndCancelFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1091")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Batch Login Demo", "batch_login_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/done"); !strings.Contains(reply, "no active") {
|
||||
t.Fatalf("inactive /done reply = %q", reply)
|
||||
}
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "@batch_login_bot")
|
||||
tooMany := strings.TrimSuffix(strings.Repeat("enable\n", maxTelegramLoginCommandsPerMessage+1), "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, tooMany); !strings.Contains(reply, "at most 32 lines") {
|
||||
t.Fatalf("oversized batch reply = %q", reply)
|
||||
}
|
||||
oversizedConfiguration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(oversizedConfiguration.AllowedURLs) != 0 || oversizedConfiguration.Client.SigningAlgorithm != "RS256" {
|
||||
t.Fatalf("configuration after oversized batch = %+v, found=%v err=%v", oversizedConfiguration, found, err)
|
||||
}
|
||||
batch := strings.Join([]string{
|
||||
"add origin http://batch.example.test:3000",
|
||||
"add redirect http://batch.example.test:3000/auth/telegram/callback",
|
||||
"algorithm ES256",
|
||||
"enable",
|
||||
}, "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, batch); !strings.Contains(reply, "Applied all 4 commands") || !strings.Contains(reply, "/done") {
|
||||
t.Fatalf("batch reply = %q", reply)
|
||||
}
|
||||
configuration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || configuration.Client.SigningAlgorithm != "ES256" || len(configuration.AllowedURLs) != 2 {
|
||||
t.Fatalf("configuration after batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
|
||||
partial := strings.Join([]string{
|
||||
"add origin http://second.example.test:3001",
|
||||
"add redirect not-a-url",
|
||||
"disable",
|
||||
}, "\n")
|
||||
partialReply := sendToBotFather(t, svc, messages, owner, partial)
|
||||
for _, want := range []string{"Applied 1 command(s) before the error", "Stopped at line 2", "1 later command(s) were not applied", "/done"} {
|
||||
if !strings.Contains(partialReply, want) {
|
||||
t.Fatalf("partial batch reply = %q, missing %q", partialReply, want)
|
||||
}
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after partial batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || !found {
|
||||
t.Fatalf("state after partial batch: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "already applied have been kept") {
|
||||
t.Fatalf("/cancel reply = %q", reply)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /cancel: found=%v err=%v", found, err)
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after /cancel = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package bots
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
revocationErr := errors.New("authorization store unavailable")
|
||||
rev := &captureRevoker{err: revocationErr}
|
||||
svc := NewService(users, botStore, messages)
|
||||
svc.SetRouterHooks(rev)
|
||||
owner := newOwner(t, users, "+2099")
|
||||
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
|
||||
|
||||
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
|
||||
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
|
||||
}
|
||||
if botStore.deleteCalls != 0 {
|
||||
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
|
||||
}
|
||||
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
|
||||
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
rev.err = nil
|
||||
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteBot after revocation recovery: %v", err)
|
||||
}
|
||||
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
|
||||
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotWriteAccessGrant(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2012")
|
||||
|
|
@ -400,11 +433,12 @@ type captureRevoker struct {
|
|||
botUserID int64
|
||||
pushedCommandsTo int64
|
||||
pushedCommands []domain.BotCommand
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
|
||||
c.botUserID = botUserID
|
||||
return nil
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -82,6 +83,7 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
|
|
@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
return func(s *Service) {
|
||||
if login != nil {
|
||||
s.telegramLogin = login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
|
||||
func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -436,6 +448,45 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// botAccountDeleter is the optional store capability used to permanently delete
|
||||
// a user-created bot. Only the Postgres store implements it, so the memory store
|
||||
// and other BotStore mocks are unaffected.
|
||||
type botAccountDeleter interface {
|
||||
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
|
||||
}
|
||||
|
||||
// DeleteBot permanently removes a user-created bot. System service bots are
|
||||
// rejected. Live sessions are dropped and the bot's caches are invalidated so
|
||||
// the deletion is visible immediately. Returns the tombstoned user.
|
||||
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
if domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
deleter, ok := s.bots.(botAccountDeleter)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
|
||||
}
|
||||
// Session revocation is part of the deletion invariant: a deleted bot must
|
||||
// never retain an authenticated connection. Fail closed before tombstoning
|
||||
// when the hook is unavailable or revocation fails.
|
||||
if s.hooks == nil {
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
|
||||
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
u, err := deleter.DeleteBotAccount(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ type countingBotStore struct {
|
|||
*memory.BotStore
|
||||
getBotCalls int
|
||||
getBotsCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotStore) reset() {
|
||||
|
|
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
|
|||
return s.BotStore.GetBots(ctx, botUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
s.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
func TestBotFatherCancelAndUnknown(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+1001")
|
||||
|
|
|
|||
|
|
@ -754,6 +754,16 @@ func normalizeStickersBotShortName(raw string) string {
|
|||
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
||||
if strings.Contains(raw, "://") {
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
query := parsed.Query()
|
||||
route := strings.Trim(parsed.Path, "/")
|
||||
if route == "" {
|
||||
route = strings.ToLower(parsed.Host)
|
||||
}
|
||||
if route == "addstickers" || route == "addemoji" {
|
||||
if shortName := query.Get("set"); shortName != "" {
|
||||
raw = shortName
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
|
||||
|
|
|
|||
|
|
@ -649,3 +649,19 @@ func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, user
|
|||
h.userID = userID
|
||||
h.kind = kind
|
||||
}
|
||||
|
||||
func TestNormalizeStickersBotShortNameAcceptsHostBasedAppLinks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "telesrv://addstickers?set=Legacy_Pack", want: "legacy_pack"},
|
||||
{raw: "owpg://tenant.example.test/addstickers?set=Hosted_Pack", want: "hosted_pack"},
|
||||
{raw: "owpg://tenant.example.test/addemoji?set=Emoji_Pack", want: "emoji_pack"},
|
||||
{raw: "https://telesrv.net/addstickers/Web_Pack", want: "web_pack"},
|
||||
} {
|
||||
if got := normalizeStickersBotShortName(tc.raw); got != tc.want {
|
||||
t.Fatalf("normalizeStickersBotShortName(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,49 @@ func (s *Service) SetVerified(ctx context.Context, channelID int64, verified boo
|
|||
return s.channels.SetChannelVerified(ctx, channelID, verified)
|
||||
}
|
||||
|
||||
// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
|
||||
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
|
||||
}
|
||||
|
||||
// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
|
||||
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
|
||||
}
|
||||
|
||||
// AdminSetUsername force-sets or clears a channel username through the admin path.
|
||||
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
|
||||
}
|
||||
|
||||
// AdminSetColor force-sets a channel name/profile color through the admin path.
|
||||
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
|
||||
}
|
||||
|
||||
// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
|
||||
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
|
||||
}
|
||||
|
||||
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
||||
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
|
|||
34
internal/app/channels/service_suggested_post.go
Normal file
34
internal/app/channels/service_suggested_post.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type suggestedPostStore interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil || req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ToggleSuggestedPostApproval(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ProcessSuggestedPostLifecycle(ctx, req)
|
||||
}
|
||||
|
|
@ -132,5 +132,8 @@ func cloneUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Service struct {
|
|||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -49,6 +50,10 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -84,6 +89,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ func cloneDialogMessages(in []domain.Message) []domain.Message {
|
|||
|
||||
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -424,6 +425,7 @@ func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMess
|
|||
|
||||
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -500,6 +502,9 @@ func cloneDialogUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -54,6 +55,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -93,6 +98,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -946,6 +952,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue