chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

20
.env.example Normal file
View file

@ -0,0 +1,20 @@
# Optional local config file for telesrv.
# Copy to .env for local development. Do not commit real tokens or passwords.
TELESRV_LISTEN=0.0.0.0:2398
TELESRV_ADVERTISE_IP=127.0.0.1
TELESRV_DC=2
TELESRV_DEV_AUTH_CODE=12345
TELESRV_POSTGRES_DSN=postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable
TELESRV_REDIS_ADDR=127.0.0.1:6399
TELESRV_REDIS_PASSWORD=
TELESRV_REDIS_DB=0
# Optional. Enables Mapbox-backed map previews and TDesktop map picker config.
TELESRV_MAPBOX_TOKEN=
TELESRV_MAPTILE_CACHE_DIR=data/maptiles
TELESRV_LANGPACK_SEED_DIR=data/langpack
TELESRV_BLOB_DIR=data/blobs
TELESRV_STICKER_SEED_DIR=data/sticker-seed

5
.gitignore vendored
View file

@ -4,8 +4,12 @@
*.test
*.out
coverage.*
node_modules/
# 本地环境 / 密钥server RSA private key 必须持久化,但禁止入库)
.env
.env.*
!.env.example
*.local
*.pem
*.key
@ -14,6 +18,7 @@ coverage.*
!/data/langpack/
!/data/langpack/**
logs/
tmp/
.gocache/
.tdesktop-e2e/

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 gramsrv authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

159
README.md
View file

@ -1,8 +1,8 @@
# gramsrv
`gramsrv` is a Go implementation of a Telegram-like MTProto server, focused on
real client compatibility, repeatable protocol research, self-hosted chat
experiments, and ongoing tracking of newer Telegram client behavior.
`gramsrv` is an open-source Telegram-like MTProto server written in Go. It is
built for real client compatibility, self-hosted chat experiments, protocol
research, and long-running work toward a practical community server.
[Website](https://telesrv.net) · [Discussion group](https://t.me/telesrv_chat) · [Channel](https://t.me/telesrv) · [中文 README](README.zh-CN.md)
@ -13,63 +13,37 @@ or sponsored by Telegram or the official Telegram team.
https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
## Highlights
## Project Traits
- **One server program to run.** After PostgreSQL and Redis are ready, the Go
server process wires together RSA key preparation, database migrations,
language pack seeding, MTProto edge handling, RPC routing, updates,
media/files, and reliable dispatch workers.
- **Multi-device is implemented.** Telegram Desktop and Android clients can use
the same server state, with scoped sessions, online fan-out, current-session
exclusion, and offline recovery through update difference APIs.
- **Maintained with Telegram version tracking.** The public baseline stays
reproducible, while newer Telegram Desktop and Android behavior is tracked
through real client traces, compatibility notes, and targeted follow-up work.
- **Optimized hot paths are part of the design.** Batched reliable outbox
delivery, warmed PostgreSQL pools, scoped session lookups, bounded RPC inputs,
and seek-style pagination reduce repeated work on chat, sync, and media paths.
- **Telegram Desktop is the primary compatibility target.** The public build
tracks a pinned TDesktop baseline and keeps compatibility work documented.
- **Android compatibility is active.** The current public screenshots include a
patched Android client connected to the same server path.
- **Core chat paths work today.** Login, users, contacts, dialogs, private
messages, supergroups/channels, media/files, profile/channel photos,
stickers, reactions, language packs, and presence are covered on the main
path.
- **Production boundaries are explicit.** Large-scale public channels,
multi-DC/file-DC/CDN, Bot API, payments, stories, Premium business logic,
abuse controls, and production object storage are outside the current public
scope.
| Status | Trait | What it means |
|---|---|---|
| ✅ | One program startup | One Go binary prepares RSA keys, runs migrations, seeds data, opens MTProto, serves RPC handlers, dispatches updates, and starts workers. |
| ✅ | Fully open server code | Protocol edge, domain services, storage, compatibility handlers, media, updates, admin surfaces, and experiments are all in this repository. |
For downloads, public information, and the current hosted experience entry,
visit [telesrv.net](https://telesrv.net). For questions, compatibility reports,
and development discussion, join [t.me/telesrv_chat](https://t.me/telesrv_chat).
## Feature Checklist
## Maintenance and Version Tracking
Everything below is an implemented server-side capability in the open-source
codebase.
`gramsrv` does not treat one pinned client build as the end of the work. The
pinned Telegram Desktop baseline keeps regressions reproducible, while newer
Telegram Desktop and Android releases are followed separately through real
client startup/sync traces, compatibility matrix updates, and focused adaptation
tasks.
| Status | Feature | What works today |
|---|---|---|
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, and layer compatibility helpers. |
| ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. |
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
| ✅ | Private chats | Send, history, read receipts, edit, delete, forward, reply, rich entities, grouped/media messages, reactions, scheduled/TTL-oriented paths. |
| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, history, send/edit/delete/read, reactions, public search, and previews. |
| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, external media fetch, web page previews, map tile cache hooks, profile/channel photos. |
| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. |
| ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. |
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, minimal Bot API gateway, and demo tools. |
| ✅ | Calls and real-time | Private call signaling foundations, group call state, SFU/TURN building blocks, liveness, and expiry workers. |
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |
| ✅ | Desktop, Android, and Web focus | Telegram Desktop is the primary target, with Android and Web compatibility paths actively covered by the same server. |
When a new Telegram client path appears, the expected workflow is to record it,
bound the inputs, decide whether it should be implemented, stubbed, or marked as
out of scope, and then keep the repository documentation in sync with the actual
behavior.
## Repository Layout
```text
cmd/telesrv/ server entrypoint
deploy/ docker-compose and PostgreSQL migrations
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend
internal/rpc/ TL router and Telegram Desktop compatibility handlers
internal/app/ domain services
internal/domain/ protocol-independent domain models
internal/store/ store interfaces and memory/postgres/redis backends
docs/ compatibility notes and module design docs
```
Some items are compatibility-first or experimental, but they are real open
server code, not hidden product-only features. The next step is making these
paths stronger together.
## Quick Start
@ -85,27 +59,31 @@ Start PostgreSQL and Redis:
docker compose -f deploy/docker-compose.yml up -d
```
Build and run the server:
Build and run the single server program:
```powershell
go build -o bin/gramsrv.exe ./cmd/telesrv
.\bin\gramsrv.exe
```
On first start, `gramsrv` creates `data/server_rsa.pem`, applies all database
migrations, seeds bundled language packs, and listens on `0.0.0.0:2398`.
On first start, `gramsrv` creates `data/server_rsa.pem`, applies database
migrations, seeds bundled language packs, prepares optional media resources,
starts MTProto on `0.0.0.0:2398`, and brings up the update/media/background
workers in the same process.
Useful development environment variables:
Useful local environment variables:
| Variable | Default | Meaning |
|---|---:|---|
| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto listen address |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP written into `help.getConfig` |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients |
| `TELESRV_DC` | `2` | self-hosted DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string |
| `TELESRV_REDIS_ADDR` | `localhost:6399` | Redis address |
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional exported sticker/reaction seed directory |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address |
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory |
| `TELESRV_BLOB_DIR` | `data/blobs` | local media blob directory |
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional sticker/reaction seed directory |
The optional sticker seed directory is skipped when it does not exist.
@ -161,22 +139,49 @@ Recommended checks:
- Check server logs for no new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`,
panic, or internal errors.
## Documentation
## Repository Layout
- [Compatibility matrix](docs/compatibility-matrix.md)
- [Telegram Desktop patch notes](docs/tdesktop-patch-notes.md)
- [Persistence layer](docs/persistence-layer.md)
- [Message module](docs/message-module.md)
- [Channel module](docs/channel-module.md)
- [Performance audit](docs/performance-audit.md)
```text
cmd/telesrv/ server entrypoint
cmd/telesrv-admin/ admin backend and web UI
deploy/ docker-compose, migrations, deploy helpers
data/ bundled language packs and optional seed data
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend
internal/rpc/ TL router and client compatibility handlers
internal/app/ domain services
internal/domain/ protocol-independent domain models
internal/store/ memory/postgres/redis storage backends
internal/seed/ bundled seed catalog loaders
internal/sfu/ real-time SFU experiments
internal/turnsrv/ TURN/STUN building blocks
```
## Contributing
## Help Improve It
Compatibility-driven contributions are welcome. Useful areas include Telegram
Desktop and Android reports, reproducible RPC traces, focused bug fixes,
new Telegram client version reports, multi-device update tests, performance
work on already implemented paths, and documentation that makes local setup
easier.
`gramsrv` will get better fastest if more people run it, break it, profile it,
and send focused improvements. Helpful contributions include:
If a change affects visible client behavior, include the client version/commit,
the RPC path you tested, and the server log result.
- Telegram Desktop and Android compatibility reports with reproducible steps.
- RPC traces for startup, sync, chat, media, calls, bots, or edge cases.
- Focused fixes for implemented paths instead of broad rewrites.
- Tests for online/offline updates, multi-device sessions, read state, media,
and channel behavior.
- Performance work on hot paths such as fan-out, pagination, storage queries,
media upload/download, and connection handling.
- Setup improvements that make the one-program local experience smoother.
If a change affects visible client behavior, please include the client
version/commit, the RPC path you tested, and whether server logs stayed free of
new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`, panic, or internal errors.
## License
`gramsrv` is released under the [Apache License 2.0](LICENSE). You may use,
modify, distribute, and use it commercially under the terms of Apache-2.0.
## Custom Development
For paid custom development, you can contact the author through the discussion
group or website. Custom work can cover server features, Telegram Desktop,
Android, Web, deployment, compatibility adaptation, or other client/server
paths around this project.

View file

@ -1,46 +1,43 @@
# gramsrv
`gramsrv` 是一个用 Go 编写的 Telegram-like MTProto server重点放在真实客户端兼容、可复现协议研究、自建聊天服务实验以及对 Telegram 客户端新版本行为的持续追踪
`gramsrv` 是一个用 Go 编写的开源 Telegram-like MTProto server面向真实客户端兼容、自建聊天实验、协议研究以及一条长期可演进的社区 server 路线
[官网](https://telesrv.net) · [讨论群](https://t.me/telesrv_chat) · [频道](https://t.me/telesrv) · [English README](README.md)
[English README](README.md) · [官网](https://telesrv.net) · [讨论群](https://t.me/telesrv_chat) · [频道](https://t.me/telesrv)
`gramsrv` 是独立的非官方项目,与 Telegram 官方及其团队没有关联,也未获得其背书或赞助。
## 演示视频
## Demo Video
https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
## 亮点
## 项目特性
- **一个 server 程序即可运行。** PostgreSQL 与 Redis 准备好后Go server 进程会编排 RSA key 准备、数据库 migration、语言包 seed、MTProto edge、RPC router、updates、media/files 和可靠投递 worker。
- **多设备已实现。** Telegram Desktop 与 Android 客户端可以使用同一套服务端状态,支持 scoped session、在线 fan-out、当前 session 排除,以及通过 updates difference API 做离线恢复。
- **持续维护并追踪 Telegram 新版本。** 公开基线保持可复现,同时通过真实客户端 trace、兼容记录和后续适配任务持续跟踪 Telegram Desktop 与 Android 新版本行为。
- **热路径优化是设计的一部分。** 可靠 outbox 批量投递、PostgreSQL 连接池预热、scoped session 查询、有界 RPC 参数和 seek 分页,减少聊天、同步与媒体路径上的重复工作。
- **Telegram Desktop 是第一兼容目标。** 当前公开版本围绕固定 TDesktop 基线推进,并把兼容性进展写入文档。
- **Android 兼容正在推进。** 当前公开截图已经包含连接到同一服务端路径的 Android 客户端。
- **核心聊天主路径可用。** 已覆盖登录、users、contacts、dialogs、私聊消息、超级群/频道、media/files、用户/频道头像、stickers、reactions、语言包与 presence。
- **生产边界明确。** 大规模公开频道、多 DC / 文件 DC / CDN、Bot API、payments、stories、Premium 商业逻辑、生产风控和生产对象存储不属于当前公开版本范围。
| 状态 | 特性 | 说明 |
|---|---|---|
| ✅ | 一个程序直接启动 | 一个 Go 二进制完成 RSA key、数据库迁移、内置数据导入、MTProto 监听、RPC handlers、updates 分发和后台 worker。 |
| ✅ | 所有 server 功能开源 | 协议接入、业务服务、存储层、兼容 handlers、媒体链路、updates、管理后台和实验模块都在本仓库。 |
下载、公开说明和当前体验入口见 [telesrv.net](https://telesrv.net)。问题交流、兼容反馈和开发讨论可以加入 [t.me/telesrv_chat](https://t.me/telesrv_chat)。
## 功能清单
## 持续维护与版本追踪
下面这些是开源代码里已经实现的 server 侧功能。
`gramsrv` 不会把某一个固定客户端版本当成终点。固定 Telegram Desktop 基线用于保证回归可复现;新的 Telegram Desktop 与 Android 版本会通过真实客户端启动/同步 trace、兼容矩阵更新和聚焦适配任务持续跟进。
| 状态 | 功能 | 当前已实现 |
|---|---|---|
| ✅ | MTProto server 接入层 | TCP transport、RSA key exchange、auth key、加密 session、salt、ack/resend、bad message、RPC dispatch、layer 兼容辅助。 |
| ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 |
| ✅ | 私聊消息 | send、history、read receipts、edit、delete、forward、reply、富文本实体、媒体/相册消息、reactions、scheduled/TTL 相关路径。 |
| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、history、send/edit/delete/read、reactions、公开搜索和预览。 |
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 |
| ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 |
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、最小 Bot API gateway、demo 工具。 |
| ✅ | 通话与实时能力 | 私聊通话信令基础、group call 状态、SFU/TURN building blocks、liveness 与 expiry worker。 |
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |
| ✅ | Desktop、Android 与 Web 兼容 | Telegram Desktop 是第一目标Android 与 Web 兼容路径也由同一套 server 持续覆盖。 |
当新的 Telegram 客户端路径出现时推荐流程是先记录调用约束输入边界再决定实现、stub 或标记为范围外,并保持仓库文档与实际行为一致。
## 仓库结构
```text
cmd/telesrv/ server 启动入口
deploy/ docker-compose 与 PostgreSQL migrations
internal/mtprotoedge/ MTProto transport、auth key、session、ack/resend
internal/rpc/ TL router 与 Telegram Desktop 兼容 handlers
internal/app/ domain services
internal/domain/ 不依赖协议生成类型的 domain models
internal/store/ store interfaces 与 memory/postgres/redis 后端
docs/ 兼容性记录与模块设计文档
```
其中一部分能力仍是兼容优先或实验性质,但它们都是真实开放的 server 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。
## 快速启动
@ -56,32 +53,34 @@ docs/ 兼容性记录与模块设计文档
docker compose -f deploy/docker-compose.yml up -d
```
编译并启动 server
编译并启动唯一的 server 程序
```powershell
go build -o bin/gramsrv.exe ./cmd/telesrv
.\bin\gramsrv.exe
```
第一次启动时,`gramsrv` 会创建 `data/server_rsa.pem`,自动执行所有数据库 migrations导入内置语言包并监听 `0.0.0.0:2398`
第一次启动时,`gramsrv` 会创建 `data/server_rsa.pem`,自动执行数据库 migrations导入内置语言包准备可选媒体资源`0.0.0.0:2398` 监听 MTProto并在同一进程里启动 updates、media、后台调度等 worker
常用开发环境变量:
常用本地环境变量:
| 变量 | 默认值 | 说明 |
|---|---:|---|
| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto 监听地址 |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 写入 `help.getConfig` 的客户端连接 IP |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP |
| `TELESRV_DC` | `2` | 自建 DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 |
| `TELESRV_REDIS_ADDR` | `localhost:6399` | Redis 地址 |
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选的 sticker/reaction 导出种子目录 |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 |
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 |
| `TELESRV_BLOB_DIR` | `data/blobs` | 本地媒体 blob 目录 |
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选 sticker/reaction 种子目录 |
如果 sticker seed 目录不存在,启动时会自动跳过。
## 客户端兼容
官方 Telegram 客户端不能直接连接 `gramsrv`,因为它们信任的是 Telegram 官方 DC 列表和 RSA keys。你可以从 [官网](https://telesrv.net) 获取体验客户端,或者自己编译带最小协议 patch 的客户端
官方 Telegram 客户端不能直接连接 `gramsrv`,因为它们信任的是 Telegram 官方 DC 列表和 RSA keys。你可以使用 [官网](https://telesrv.net) 提供的体验客户端,也可以自己做最小协议 patch
当前 Telegram Desktop 基线:
@ -101,11 +100,11 @@ openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub
2. 把 `kPublicRSAKeys``kTestPublicRSAKeys` 都替换为 `data/server_rsa.pub`
3. 给 built-in DC flags 加上 `Flag::f_tcpo_only`
客户端 patch 应保持最小:只改 endpoint、RSA key 和 TCP-only flags。
客户端 patch 应保持最小:只改 endpoint、RSA key 和 TCP-only flags,不要把 UI 改动混入协议兼容 patch
## 多设备冒烟验证
## 多冒烟验证
使用不同的客户端工作目录,避免多个 session 共用本地 `tdata`
用不同的 TDesktop working directory避免 Alice 和 Bob 共用同一个 `tdata`
```powershell
$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe"
@ -113,26 +112,49 @@ Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice")
Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob")
```
用不同手机号登录。本地开发默认验证码是 `12345`,除非你修改了 `TELESRV_DEV_AUTH_CODE`
两个不同手机号登录。本地开发默认验证码是 `12345`,除非你修改了 `TELESRV_DEV_AUTH_CODE`
推荐检查:
- 在两个用户之间发送私聊消息、贴纸、媒体、回复、转发、编辑、删除和已读回执
- 保持一个设备在线,重启另一个设备,验证离线 `updates.getDifference` 恢复。
- 同一账号打开多个 session确认当前 session 不重复收到 echo其它在线 session 能收到更新
- 两个用户之间发送私聊消息、sticker、媒体、reply、forward、edit、delete 和 read receipts
- 一个设备保持在线,另一个设备重启,验证离线 `updates.getDifference` 恢复。
- 同一账号多 session 登录,确认当前 session 不重复 echo其它在线 session 能收到 updates
- 检查 server 日志没有新增 `NOT_IMPLEMENTED``Unhandled RPC``bad_msg`、panic 或 internal error。
## 文档
## 仓库结构
- [兼容矩阵](docs/compatibility-matrix.md)
- [Telegram Desktop patch notes](docs/tdesktop-patch-notes.md)
- [持久化层设计](docs/persistence-layer.md)
- [消息模块](docs/message-module.md)
- [频道模块](docs/channel-module.md)
- [性能审计](docs/performance-audit.md)
```text
cmd/telesrv/ server 启动入口
cmd/telesrv-admin/ 管理后台 backend 与 web UI
deploy/ docker-compose、migrations、部署辅助
data/ 内置语言包与可选种子数据
internal/mtprotoedge/ MTProto transport、auth key、session、ack/resend
internal/rpc/ TL router 与客户端兼容 handlers
internal/app/ domain services
internal/domain/ 不依赖协议生成类型的 domain models
internal/store/ memory/postgres/redis 存储后端
internal/seed/ 内置 seed catalog 加载器
internal/sfu/ SFU 实验模块
internal/turnsrv/ TURN/STUN building blocks
```
## 参与贡献
## 一起优化
欢迎围绕兼容性目标参与贡献。现在最有价值的方向包括 Telegram Desktop / Android 兼容反馈、新 Telegram 客户端版本反馈、可复现 RPC trace、聚焦的小 bug fix、多设备 update 测试、已实现路径的性能优化,以及让本地启动更顺滑的文档改进。
`gramsrv` 非常欢迎大家一起跑、一起测、一起拆问题、一起优化。尤其欢迎这些贡献:
如果改动会影响客户端可见行为,请写清客户端版本/commit、验证过的 RPC 路径,以及 server 日志结果。
- Telegram Desktop 和 Android 兼容性报告,最好带可复现步骤。
- 启动、同步、聊天、媒体、通话、bots 或边界场景的 RPC trace。
- 围绕已实现路径的小而准的 bug fix。
- 在线/离线 updates、多端 session、read state、媒体、频道行为的测试。
- fan-out、分页、存储查询、媒体上传/下载、连接层等热点路径的性能优化。
- 让“一个程序直接启动”的本地体验更顺滑的改进。
如果改动会影响客户端可见行为,请说明客户端版本/commit、验证过的 RPC 路径,以及 server 日志是否没有新增 `NOT_IMPLEMENTED``Unhandled RPC``bad_msg`、panic 或 internal error。
## 授权协议
`gramsrv` 使用 [Apache License 2.0](LICENSE) 发布。你可以在 Apache-2.0 条款下使用、修改、分发,也可以商用。
## 付费定制开发
如需付费定制开发功能,可以通过讨论群或官网联系作者。定制范围不限于某一端,可覆盖 server 功能、Telegram Desktop、Android、Web、部署、兼容适配或围绕本项目的其它客户端/服务端路径。

400
cmd/appearancefetch/main.go Normal file
View file

@ -0,0 +1,400 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
//
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
// 子命令:
//
// appearancefetch sendcode env: PHONE 发送登录码,打印 CODE_HASH
// appearancefetch fetch <out> <carry> env: PHONE CODE CODE_HASH PASSWORD 登录+拉取+生成
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"telesrv/internal/seed/appearance"
)
const (
apiID = 17349
apiHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: appearancefetch sendcode | appearancefetch fetch <out_dir> <carry_json>")
os.Exit(2)
}
cmd := os.Args[1]
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
client := telegram.NewClient(apiID, apiHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
err := client.Run(ctx, func(ctx context.Context) error {
switch cmd {
case "sendcode":
return doSendCode(ctx, client)
case "fetch":
if len(os.Args) < 3 {
return errors.New("fetch needs <out_dir>")
}
return doFetch(ctx, client, os.Args[2])
default:
return fmt.Errorf("unknown cmd %q", cmd)
}
})
if err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func doSendCode(ctx context.Context, client *telegram.Client) error {
phone := os.Getenv("PHONE")
if phone == "" {
return errors.New("PHONE env required")
}
sent, err := client.Auth().SendCode(ctx, phone, auth.SendCodeOptions{})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
code, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode = %T", sent)
}
fmt.Println("CODE_HASH=" + code.PhoneCodeHash)
return nil
}
func doFetch(ctx context.Context, client *telegram.Client, outDir string) error {
phone := os.Getenv("PHONE")
code := os.Getenv("CODE")
codeHash := os.Getenv("CODE_HASH")
password := os.Getenv("PASSWORD")
status, err := client.Auth().Status(ctx)
if err != nil {
return fmt.Errorf("auth status: %w", err)
}
if !status.Authorized {
_, err := client.Auth().SignIn(ctx, phone, code, codeHash)
if errors.Is(err, auth.ErrPasswordAuthNeeded) {
if _, err := client.Auth().Password(ctx, password); err != nil {
return fmt.Errorf("2FA: %w", err)
}
} else if err != nil {
return fmt.Errorf("signIn: %w", err)
}
fmt.Println("[authorized]")
} else {
fmt.Println("[already authorized]")
}
api := client.API()
docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(thumbsDir, 0o755); err != nil {
return err
}
dl := downloader.NewDownloader()
download := func(doc *tg.Document, thumb string) ([]byte, error) {
loc := &tg.InputDocumentFileLocation{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: doc.FileReference,
ThumbSize: thumb,
}
var buf bytes.Buffer
if _, err := dl.Download(api, loc).Stream(ctx, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 转一个 tg.Document → appearance.Document 并下载正文 + "m" 缩略图。
convDoc := func(dc tg.DocumentClass) (appearance.Document, error) {
doc, ok := dc.(*tg.Document)
if !ok || doc.ID == 0 {
return appearance.Document{}, nil
}
out := appearance.Document{
ID: doc.ID,
AccessHash: doc.AccessHash,
Date: doc.Date,
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
}
for _, a := range doc.Attributes {
switch attr := a.(type) {
case *tg.DocumentAttributeImageSize:
out.Attributes = append(out.Attributes, appearance.DocumentAttribute{Kind: "image_size", W: attr.W, H: attr.H})
case *tg.DocumentAttributeFilename:
out.Attributes = append(out.Attributes, appearance.DocumentAttribute{Kind: "filename", FileName: attr.FileName})
}
}
// 正文
data, err := download(doc, "")
if err != nil {
return appearance.Document{}, fmt.Errorf("download doc %d: %w", doc.ID, err)
}
name := fmt.Sprintf("%d.dat", doc.ID)
if err := os.WriteFile(filepath.Join(docsDir, name), data, 0o644); err != nil {
return appearance.Document{}, err
}
sum := sha256.Sum256(data)
out.Path = "default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图
for _, t := range doc.Thumbs {
ps, ok := t.(*tg.PhotoSize)
if !ok || ps.Type != "m" {
continue
}
tdata, err := download(doc, "m")
if err != nil {
return appearance.Document{}, fmt.Errorf("download thumb %d: %w", doc.ID, err)
}
if err := os.WriteFile(filepath.Join(thumbsDir, name), tdata, 0o644); err != nil {
return appearance.Document{}, err
}
tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
})
break
}
return out, nil
}
convWallpaperSettings := func(s tg.WallPaperSettings) appearance.WallpaperSettings {
out := appearance.WallpaperSettings{}
if v, ok := s.GetBackgroundColor(); ok {
out.BackgroundColor = v
}
if v, ok := s.GetSecondBackgroundColor(); ok {
out.SecondBackgroundColor = v
}
if v, ok := s.GetThirdBackgroundColor(); ok {
out.ThirdBackgroundColor = v
}
if v, ok := s.GetFourthBackgroundColor(); ok {
out.FourthBackgroundColor = v
}
if v, ok := s.GetIntensity(); ok {
out.Intensity = v
}
if v, ok := s.GetRotation(); ok {
out.Rotation = v
}
out.Blur = s.Blur
out.Motion = s.Motion
return out
}
convWallpaper := func(wc tg.WallPaperClass) (appearance.Wallpaper, error) {
switch wp := wc.(type) {
case *tg.WallPaper:
doc, err := convDoc(wp.Document)
if err != nil {
return appearance.Wallpaper{}, err
}
out := appearance.Wallpaper{
ID: wp.ID, AccessHash: wp.AccessHash, Type: 0,
Default: wp.Default, Pattern: wp.Pattern, Dark: wp.Dark,
Slug: wp.Slug, Document: doc,
}
if s, ok := wp.GetSettings(); ok {
out.Settings = convWallpaperSettings(s)
}
return out, nil
case *tg.WallPaperNoFile:
out := appearance.Wallpaper{ID: wp.ID, Type: 1, Default: wp.Default, Dark: wp.Dark}
if s, ok := wp.GetSettings(); ok {
out.Settings = convWallpaperSettings(s)
}
return out, nil
}
return appearance.Wallpaper{}, nil
}
// ---- 墙纸 ----
wpRes, err := api.AccountGetWallPapers(ctx, 0)
if err != nil {
return fmt.Errorf("getWallPapers: %w", err)
}
var wallpapers []appearance.Wallpaper
if w, ok := wpRes.(*tg.AccountWallPapers); ok {
for _, wc := range w.Wallpapers {
conv, err := convWallpaper(wc)
if err != nil {
return err
}
if conv.ID != 0 {
wallpapers = append(wallpapers, conv)
}
}
}
fmt.Printf("[wallpapers] %d\n", len(wallpapers))
// ---- 聊天主题 ----
baseThemeName := func(b tg.BaseThemeClass) string {
switch b.(type) {
case *tg.BaseThemeClassic:
return "classic"
case *tg.BaseThemeDay:
return "day"
case *tg.BaseThemeNight:
return "night"
case *tg.BaseThemeTinted:
return "tinted"
case *tg.BaseThemeArctic:
return "arctic"
}
return ""
}
var chatThemes []appearance.ChatTheme
thRes, err := api.AccountGetChatThemes(ctx, 0)
if err != nil {
return fmt.Errorf("getChatThemes: %w", err)
}
if t, ok := thRes.(*tg.AccountThemes); ok {
for _, theme := range t.Themes {
ct := appearance.ChatTheme{
ID: theme.ID, AccessHash: theme.AccessHash, Slug: theme.Slug,
Title: theme.Title, Emoticon: theme.Emoticon, Default: theme.Default,
}
for _, s := range theme.Settings {
ts := appearance.ThemeSettings{
BaseTheme: baseThemeName(s.BaseTheme),
AccentColor: s.AccentColor,
}
if v, ok := s.GetOutboxAccentColor(); ok {
ts.OutboxAccentColor = v
}
if v, ok := s.GetMessageColors(); ok {
ts.MessageColors = v
}
if w, ok := s.GetWallpaper(); ok {
wp, err := convWallpaper(w)
if err != nil {
return err
}
ts.Wallpaper = wp
}
ct.Settings = append(ct.Settings, ts)
}
chatThemes = append(chatThemes, ct)
}
}
fmt.Printf("[chat_themes] %d\n", len(chatThemes))
// ---- peer colors / profile colors 从官方拉 ----
mapColorSet := func(c tg.HelpPeerColorSetClass) *appearance.ColorSet {
switch s := c.(type) {
case *tg.HelpPeerColorSet:
return &appearance.ColorSet{PredicateName: "help.peerColorSet", Colors: append([]int(nil), s.Colors...)}
case *tg.HelpPeerColorProfileSet:
return &appearance.ColorSet{
PredicateName: "help.peerColorProfileSet",
PaletteColors: append([]int(nil), s.PaletteColors...),
BgColors: append([]int(nil), s.BgColors...),
StoryColors: append([]int(nil), s.StoryColors...),
}
}
return nil
}
fetchColors := func(profile bool) ([]appearance.ColorOption, error) {
var res tg.HelpPeerColorsClass
var err error
if profile {
res, err = api.HelpGetPeerProfileColors(ctx, 0)
} else {
res, err = api.HelpGetPeerColors(ctx, 0)
}
if err != nil {
return nil, err
}
pc, ok := res.(*tg.HelpPeerColors)
if !ok {
return nil, nil
}
out := make([]appearance.ColorOption, 0, len(pc.Colors))
for _, o := range pc.Colors {
co := appearance.ColorOption{ID: o.ColorID, Hidden: o.GetHidden()}
if v, ok := o.GetChannelMinLevel(); ok {
co.ChannelMinLevel = v
}
if c, ok := o.GetColors(); ok {
co.Colors = mapColorSet(c)
}
if c, ok := o.GetDarkColors(); ok {
co.DarkColors = mapColorSet(c)
}
out = append(out, co)
}
return out, nil
}
peerColors, err := fetchColors(false)
if err != nil {
return fmt.Errorf("getPeerColors: %w", err)
}
peerProfileColors, err := fetchColors(true)
if err != nil {
return fmt.Errorf("getPeerProfileColors: %w", err)
}
fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors))
catalog := appearance.Catalog{
Source: "official telegram (appearancefetch)",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Notes: appearance.Notes{
Server: "official",
ChatThemesTotal: len(chatThemes),
WallpaperDocumentsBucket: "documents",
WallpaperThumbsBucket: "thumbs",
},
ChatThemes: chatThemes,
Wallpapers: wallpapers,
PeerColors: peerColors,
PeerProfileColors: peerProfileColors,
}
out, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (wallpapers=%d chat_themes=%d peer_colors=%d profile=%d)\n",
jsonPath, len(wallpapers), len(chatThemes), len(catalog.PeerColors), len(catalog.PeerProfileColors))
return nil
}

307
cmd/botcheck/main.go Normal file
View file

@ -0,0 +1,307 @@
// Command botcheck 是 bot 客户端验证工具(开发用,非生产组件)。
//
// 它用 BotFather 发的 token 经 auth.importBotAuthorization 登录本地 telesrv
// 自检 bot 身份 / updates 状态 / userFull.bot_info并可选以 echo 模式持续回显
// 收到的私聊消息,验证 bot 收发闭环(在线推送 → bot 处理 → bot 回复)。
//
// 用法:
//
// go run ./cmd/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
//
// 连接生产 telesrvobfuscated TCP靠 DCOption.TCPObfuscatedOnly=true
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListenerobfuscated2.Accept(conn, nil))。
// gotd 内置 dcs.Plain 的 obfuscated 路径走 MTProxy强制 secret不适用这里。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
// obfuscated2 握手携带 intermediate codec 的 init tag空 secret = 标准 obfuscation。
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
// NoHeadertag 已由 obf 握手发出codec 不再重发。
proto := transport.NewProtocol(func() transport.Codec {
return codec.NoHeader{Codec: codec.Intermediate{}}
})
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>,默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径(仅读 public key")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
echo := flag.Bool("echo", false, "登录后持续 echo 收到的私聊消息")
sendTo := flag.Int64("send-to", 0, "主动发送目标 user_id验证 bot 主动发起消息)")
sendText := flag.String("send-text", "", "主动发送的文本")
runFor := flag.Duration("for", 0, "echo 模式运行时长0=直到 Ctrl+C")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, err := strconv.Atoi(portStr)
if err != nil {
logger.Fatal("解析端口失败", zap.Error(err))
}
var client *telegram.Client
handler := telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error {
if !*echo {
return nil
}
return echoUpdates(ctx, tg.NewClient(client), u, logger)
})
client = telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{
{ID: *dcID, IPAddress: host, Port: port, Static: true},
}},
Logger: logzap.New(logger.Named("client")),
UpdateHandler: handler,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
authz, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: *apiID, APIHash: *apiHash, BotAuthToken: *token,
})
if err != nil {
return fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("authorization 类型 = %Twant *tg.AuthAuthorization", authz)
}
self, ok := a.User.(*tg.User)
if !ok {
return fmt.Errorf("self 类型 = %Twant *tg.User", a.User)
}
ver, hasVer := self.GetBotInfoVersion()
fmt.Println("==== 登录成功 ====")
fmt.Printf(" id=%d username=@%s name=%q bot=%v self=%v bot_info_version=%d(present=%v)\n",
self.ID, self.Username, self.FirstName, self.Bot, self.Self, ver, hasVer)
if !self.Bot || !hasVer {
fmt.Println(" [警告] self 缺 bot flag 或 bot_info_version —— TDesktop 不会当 bot 处理")
}
if _, hasStatus := self.GetStatus(); hasStatus {
fmt.Println(" [警告] bot 携带 status —— 官方 bot 不应有 presence")
}
if self.Phone != "" {
fmt.Printf(" [警告] bot 携带 phone=%q —— 官方 bot 无手机号\n", self.Phone)
}
state, err := raw.UpdatesGetState(ctx)
if err != nil {
return fmt.Errorf("getState: %w", err)
}
fmt.Printf(" getState: pts=%d qts=%d date=%d seq=%d\n", state.Pts, state.Qts, state.Date, state.Seq)
full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
return fmt.Errorf("getFullUser: %w", err)
}
if bi, ok := full.FullUser.GetBotInfo(); ok {
uid, _ := bi.GetUserID()
desc, _ := bi.GetDescription()
cmds, _ := bi.GetCommands()
fmt.Printf(" bot_info: user_id=%d description=%q commands=%d\n", uid, desc, len(cmds))
if uid != self.ID {
fmt.Printf(" [警告] bot_info.user_id=%d != self.id=%d —— TDesktop 会整体忽略 bot_info\n", uid, self.ID)
}
for _, c := range cmds {
fmt.Printf(" /%s - %s\n", c.Command, c.Description)
}
} else {
fmt.Println(" [警告] userFull 缺 bot_info —— TDesktop 会反复重拉 getFullUser")
}
fmt.Println("==== 自检通过 ====")
// 主动发起bot 主动给指定用户发消息(用户须先与 bot 交互过)。
// access_hash 经 getUsers 解析bot 重新登录时手里没有该用户的 update
if *sendTo != 0 && *sendText != "" {
target := &tg.InputPeerUser{UserID: *sendTo}
if list, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: *sendTo}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == *sendTo {
target.AccessHash = u.AccessHash
fmt.Printf(" 解析目标用户: id=%d username=@%s name=%q\n", u.ID, u.Username, u.FirstName)
}
}
} else {
fmt.Printf(" [警告] getUsers(%d) 失败: %v仍尝试 access_hash=0 发送)\n", *sendTo, err)
}
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: target,
Message: *sendText,
RandomID: rid,
}); err != nil {
return fmt.Errorf("主动发送: %w", err)
}
fmt.Printf(">> 已主动发送给 %d: %q\n", *sendTo, *sendText)
}
if !*echo {
return nil
}
fmt.Println(">> echo 模式:从 TDesktop/Android 给这个 bot 发私聊消息bot 会回 \"echo: <原文>\"")
if *runFor > 0 {
fmt.Printf(">> 运行 %s 后自动退出(或 Ctrl+C\n", *runFor)
t := time.NewTimer(*runFor)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
return nil
}
fmt.Println(">> Ctrl+C 退出")
<-ctx.Done()
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// echoUpdates 解析推送的 updates对收到的 incoming 私聊消息回 "echo: <原文>"。
func echoUpdates(ctx context.Context, raw *tg.Client, u tg.UpdatesClass, logger *zap.Logger) error {
var ups []tg.UpdateClass
var users []tg.UserClass
switch v := u.(type) {
case *tg.Updates:
ups, users = v.Updates, v.Users
case *tg.UpdatesCombined:
ups, users = v.Updates, v.Users
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
return nil
}
hashByID := make(map[int64]int64, len(users))
for _, uc := range users {
if usr, ok := uc.(*tg.User); ok {
hashByID[usr.ID] = usr.AccessHash
}
}
for _, up := range ups {
nm, ok := up.(*tg.UpdateNewMessage)
if !ok {
continue
}
msg, ok := nm.Message.(*tg.Message)
if !ok || msg.Out {
continue
}
peer, ok := msg.PeerID.(*tg.PeerUser)
if !ok {
continue
}
from := peer.UserID
fmt.Printf("<< 收到来自 %d: %q\n", from, msg.Message)
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: from, AccessHash: hashByID[from]},
Message: "echo: " + msg.Message,
RandomID: rid,
}); err != nil {
logger.Warn("echo 回复失败", zap.Int64("to", from), zap.Error(err))
continue
}
fmt.Printf(">> 已回复 %d: %q\n", from, "echo: "+msg.Message)
}
return nil
}
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

479
cmd/botdemo/main.go Normal file
View file

@ -0,0 +1,479 @@
// Command botdemo 是一个「定时向群/用户主动发消息」的 bot 参考示例(开发/教学用)。
//
// 它演示一个外部 bot 程序如何:
// 1. 用 BotFather 发的 token 经 auth.importBotAuthorization 登录 telesrv
// 2. 解析目标 peer超级群 channel 或用户 user
// 3. 在一个定时循环里,按设定间隔【主动】发送三种内容:
// - 纯文本 messages.sendMessage
// - 图片 upload 文件 + messages.sendMedia(inputMediaUploadedPhoto)
// - 图文 (同上,但带 caption
//
// bot 在群里发消息和任何普通成员一样走 messages.sendMessage/sendMedia无需被「@/命令」
// 触发——这里的定时器就是「主动发」的触发源;换成业务事件/cron 也是同一套调用。隐私模式
// bot_chat_history只影响 bot 能【收到】哪些消息,不限制它【发送】。
//
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
//
// go run ./cmd/botdemo \
// -token "<bot_id>:<secret>" \
// -chat <channel_id> -chat-hash <access_hash> \
// -interval 15s -mode rotate
//
// 其它常用参数:
//
// -to <user_id> -to-hash <hash> # 改为给某个用户私聊发(需用户先与 bot 交互过)
// -mode text|photo|caption|rotate # 只发文本 / 只发图片 / 只发图文 / 三者轮流(默认 rotate
// -image path/to/pic.jpg # 指定图片文件;不指定则程序自动生成一张 PNG
// -text "..." -caption "..." # 自定义文本与图文说明(程序会自动追加序号+时间使每条不同)
// -count 5 # 发 5 条后退出(默认 0 = 一直发到 Ctrl+C
//
// 连接 telesrv 用标准无-secret MTProto TCP obfuscationobfuscated2与生产 server 一致。
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 telesrv
// 匹配生产 server 的 transport.ObfuscatedListener。gotd 内置 dcs.Plain 的 obfuscated 路径
// 走 MTProxy强制 secret不适用这里所以自定义一个 Resolver。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
type config struct {
chatID, chatHash int64
toID, toHash int64
interval time.Duration
count int
mode string
text, caption string
imagePath string
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>(默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA 公钥路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
chatID := flag.Int64("chat", 0, "目标超级群 channel id非 0 即发到群)")
chatHash := flag.Int64("chat-hash", 0, "目标群 access_hashtelesrv 下可留 0")
toID := flag.Int64("to", 0, "目标用户 id-chat 为 0 时改为私聊该用户)")
toHash := flag.Int64("to-hash", 0, "目标用户 access_hashtelesrv 下可留 0")
interval := flag.Duration("interval", 15*time.Second, "定时发送间隔")
count := flag.Int("count", 0, "发送条数上限0 = 一直发到 Ctrl+C")
mode := flag.String("mode", "rotate", "发送内容text | photo | caption | rotate")
text := flag.String("text", "telesrv bot demo · 定时主动消息", "文本内容(会自动追加 #序号+时间)")
caption := flag.String("caption", "telesrv bot demo · 图文消息", "图文说明(会自动追加 #序号+时间)")
imagePath := flag.String("image", "", "图片文件路径(留空则程序自动生成一张 PNG")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
if *chatID == 0 && *toID == 0 {
fmt.Fprintln(os.Stderr, "需指定目标:-chat <群id>(或 -to <用户id>")
os.Exit(2)
}
switch *mode {
case "text", "photo", "caption", "rotate":
default:
fmt.Fprintf(os.Stderr, "未知 -mode=%qtext|photo|caption|rotate\n", *mode)
os.Exit(2)
}
cfg := config{
chatID: *chatID, chatHash: *chatHash, toID: *toID, toHash: *toHash,
interval: *interval, count: *count, mode: *mode,
text: *text, caption: *caption, imagePath: *imagePath,
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
api := tg.NewClient(client)
// 1) 登录为 bot。
self, err := loginBot(ctx, api, *apiID, *apiHash, *token)
if err != nil {
return err
}
fmt.Printf("==== bot 登录成功:@%s (id=%d) ====\n", self.Username, self.ID)
// 2) 解析目标 peer。
peer, label, err := resolveTarget(ctx, api, cfg)
if err != nil {
return err
}
fmt.Printf(">> 目标:%s\n>> 间隔:%s模式%s上限%s\n", label, cfg.interval, cfg.mode, countLabel(cfg.count))
// 3) 定时主动发送。
return runLoop(ctx, api, uploader.NewUploader(api), peer, cfg, logger)
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// loginBot 用 token 登录并返回 self 用户。
func loginBot(ctx context.Context, api *tg.Client, apiID int, apiHash, token string) (*tg.User, error) {
authz, err := api.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: apiID, APIHash: apiHash, BotAuthToken: token,
})
if err != nil {
return nil, fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return nil, fmt.Errorf("authorization 类型 = %T", authz)
}
self, ok := a.User.(*tg.User)
if !ok || !self.Bot {
return nil, fmt.Errorf("登录账号不是 bot%T", a.User)
}
return self, nil
}
// resolveTarget 把命令行目标解析成 InputPeer。群优先access_hash 给 0 时尝试经
// getChannels/getUsers 解析出真实 hashtelesrv 允许 hash=0 旁路,但解析更稳、也更贴近真实客户端)。
func resolveTarget(ctx context.Context, api *tg.Client, cfg config) (tg.InputPeerClass, string, error) {
if cfg.chatID != 0 {
hash := cfg.chatHash
title := ""
if res, err := api.ChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: cfg.chatID, AccessHash: cfg.chatHash},
}); err == nil {
for _, c := range res.GetChats() {
if ch, ok := c.(*tg.Channel); ok && ch.ID == cfg.chatID {
hash, title = ch.AccessHash, ch.Title
}
}
}
return &tg.InputPeerChannel{ChannelID: cfg.chatID, AccessHash: hash},
fmt.Sprintf("超级群 %q (id=%d)", title, cfg.chatID), nil
}
hash := cfg.toHash
name := ""
if list, err := api.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: cfg.toID, AccessHash: cfg.toHash}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == cfg.toID {
hash, name = u.AccessHash, u.FirstName
}
}
}
return &tg.InputPeerUser{UserID: cfg.toID, AccessHash: hash},
fmt.Sprintf("用户 %q (id=%d)", name, cfg.toID), nil
}
// runLoop 是定时主动发送的核心:每 interval 发一条,按 mode 选内容count>0 时发够即停。
func runLoop(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, cfg config, logger *zap.Logger) error {
ticker := time.NewTicker(cfg.interval)
defer ticker.Stop()
seq := 0
send := func() error {
seq++
kind := pickKind(cfg.mode, seq)
stamp := time.Now().Format("15:04:05")
switch kind {
case "text":
msg := fmt.Sprintf("%s · #%d · %s", cfg.text, seq, stamp)
if err := sendText(ctx, api, peer, msg); err != nil {
return err
}
fmt.Printf("[%d] 文本已发:%q\n", seq, msg)
case "photo":
if err := sendPhoto(ctx, api, up, peer, seq, "", cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图片已发\n", seq)
case "caption":
captionText := fmt.Sprintf("%s · #%d · %s", cfg.caption, seq, stamp)
if err := sendPhoto(ctx, api, up, peer, seq, captionText, cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图文已发:%q\n", seq, captionText)
}
return nil
}
// 立即发第一条(不必等满一个 interval随后按 ticker 周期发。
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
continue
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
}
}
}
// pickKind 决定本次发什么固定模式直接返回rotate 模式按序号轮流 text→photo→caption。
func pickKind(mode string, seq int) string {
if mode != "rotate" {
return mode
}
switch (seq - 1) % 3 {
case 0:
return "text"
case 1:
return "photo"
default:
return "caption"
}
}
// sendText 发一条纯文本消息。
func sendText(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, message string) error {
rid, _ := randInt64()
_, err := api.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: peer,
Message: message,
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMessage: %w", err)
}
return nil
}
// sendPhoto 上传一张图片并发出caption 非空即「图文」,为空即纯图片。
// 图片来源cfg.imagePath 指定的文件,或程序按序号生成的 PNG保证每条都不一样
func sendPhoto(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, seq int, caption, imagePath string) error {
var (
file tg.InputFileClass
err error
)
if imagePath != "" {
// uploader 会自动分片 upload.saveFilePart大文件走 big-file 路径)。
file, err = up.FromPath(ctx, imagePath)
if err != nil {
return fmt.Errorf("上传图片文件 %q: %w", imagePath, err)
}
} else {
data := generatePNG(seq)
file, err = up.FromBytes(ctx, fmt.Sprintf("botdemo-%d.png", seq), data)
if err != nil {
return fmt.Errorf("上传生成图片: %w", err)
}
}
rid, _ := randInt64()
_, err = api.MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
Peer: peer,
Media: &tg.InputMediaUploadedPhoto{File: file},
Message: caption, // 空 = 纯图片;非空 = 图文(图片 + 说明文字)
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMedia(photo): %w", err)
}
return nil
}
// generatePNG 程序化生成一张 600x400 的 PNG颜色随序号变化、并画一个随序号移动的方块
// 让连发的每张图肉眼可区分。纯标准库实现,无需外部图片文件,方便直接跑 demo。
func generatePNG(seq int) []byte {
const w, h = 600, 400
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 背景:按序号轮转色相的横向渐变。
bg := hsv(float64((seq*37)%360), 0.55, 0.95)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
shade := uint8(40 + (x*40)/w)
img.Set(x, y, color.RGBA{
R: clamp(int(bg.R) - 20 + int(shade)),
G: clamp(int(bg.G) - 20 + int(shade)),
B: clamp(int(bg.B) - 20 + int(shade)),
A: 255,
})
}
}
// 一个随序号横向移动的对比色方块——直观体现「这是第几条」。
block := hsv(float64((seq*37+180)%360), 0.8, 0.9)
bx := 30 + (seq*45)%(w-130)
for y := 150; y < 250; y++ {
for x := bx; x < bx+100 && x < w; x++ {
img.Set(x, y, block)
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// hsv 把 HSV(h∈[0,360), s,v∈[0,1]) 转成 RGBA标准库无 HSV简单实现一个
func hsv(hDeg, s, v float64) color.RGBA {
c := v * s
x := c * (1 - absf(modf(hDeg/60, 2)-1))
m := v - c
var r, g, b float64
switch {
case hDeg < 60:
r, g, b = c, x, 0
case hDeg < 120:
r, g, b = x, c, 0
case hDeg < 180:
r, g, b = 0, c, x
case hDeg < 240:
r, g, b = 0, x, c
case hDeg < 300:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
return color.RGBA{
R: uint8((r + m) * 255),
G: uint8((g + m) * 255),
B: uint8((b + m) * 255),
A: 255,
}
}
func absf(f float64) float64 {
if f < 0 {
return -f
}
return f
}
func modf(a, m float64) float64 {
for a >= m {
a -= m
}
for a < 0 {
a += m
}
return a
}
func clamp(v int) uint8 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}
func countLabel(count int) string {
if count <= 0 {
return "无限"
}
return strconv.Itoa(count)
}
// randInt64 生成非零随机 random_id防 (sender,random_id) 幂等键碰撞)。
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

190
cmd/catalogfetch/main.go Normal file
View file

@ -0,0 +1,190 @@
// Command catalogfetch 从官方 Telegram 拉取「目录/参考数据」并写成 telesrv
// internal/seed/catalog 的 JSON 种子:
// - 国家区号 help.getCountriesList (免登)
// - 时区 help.getTimezonesList (免登)
// - emoji 分组 messages.getEmojiGroups (需登录)
// - emoji 关键词 messages.getEmojiKeywords (需登录)
//
// 免登项即使会话未授权也能拉;emoji 两项需要授权会话,故复用 appearancefetch 建立的
// 已登录会话(SESSION env,默认 /tmp/appearance.session)。api 凭据用 TDesktop 开源公开 id/hash。
//
// 用法: SESSION=/tmp/appearance.session catalogfetch <out_dir> [langCode=en]
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
type countryCodeJSON struct {
CountryCode string `json:"country_code"`
Prefixes []string `json:"prefixes,omitempty"`
Patterns []string `json:"patterns,omitempty"`
}
type countryJSON struct {
ISO2 string `json:"iso2"`
DefaultName string `json:"default_name"`
Name string `json:"name,omitempty"`
Hidden bool `json:"hidden,omitempty"`
CountryCodes []countryCodeJSON `json:"country_codes"`
}
type countriesFile struct {
Countries []countryJSON `json:"countries"`
}
type timezoneJSON struct {
ID string `json:"id"`
Name string `json:"name"`
UTCOffset int `json:"utc_offset"`
}
type timezonesFile struct {
Timezones []timezoneJSON `json:"timezones"`
}
type emojiGroupJSON struct {
Title string `json:"title"`
IconEmojiID int64 `json:"icon_emoji_id"`
Emoticons []string `json:"emoticons"`
}
type emojiGroupsFile struct {
Groups []emojiGroupJSON `json:"groups"`
}
type emojiKeywordJSON struct {
Keyword string `json:"keyword"`
Emoticons []string `json:"emoticons"`
}
type emojiKeywordsFile struct {
LangCode string `json:"lang_code"`
Version int `json:"version"`
Keywords []emojiKeywordJSON `json:"keywords"`
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session catalogfetch <out_dir> [langCode=en]")
os.Exit(2)
}
out := os.Args[1]
lang := "en"
if len(os.Args) > 2 {
lang = os.Args[2]
}
if err := os.MkdirAll(out, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if status, err := client.Auth().Status(ctx); err == nil {
fmt.Printf("[session %s authorized=%v]\n", sessionPath(), status.Authorized)
}
// 国家区号(免登)
if cl, err := api.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: lang, Hash: 0}); err != nil {
fmt.Fprintln(os.Stderr, "skip countries:", err)
} else if full, ok := cl.(*tg.HelpCountriesList); ok {
var f countriesFile
for _, c := range full.Countries {
cj := countryJSON{ISO2: c.ISO2, DefaultName: c.DefaultName, Name: c.Name, Hidden: c.Hidden}
for _, cc := range c.CountryCodes {
cj.CountryCodes = append(cj.CountryCodes, countryCodeJSON{CountryCode: cc.CountryCode, Prefixes: cc.Prefixes, Patterns: cc.Patterns})
}
f.Countries = append(f.Countries, cj)
}
if err := writeJSON(filepath.Join(out, "countries.json"), f); err != nil {
return err
}
fmt.Printf("[countries] %d\n", len(f.Countries))
}
// 时区(免登)
if tl, err := api.HelpGetTimezonesList(ctx, 0); err != nil {
fmt.Fprintln(os.Stderr, "skip timezones:", err)
} else if full, ok := tl.(*tg.HelpTimezonesList); ok {
var f timezonesFile
for _, t := range full.Timezones {
f.Timezones = append(f.Timezones, timezoneJSON{ID: t.ID, Name: t.Name, UTCOffset: t.UtcOffset})
}
if err := writeJSON(filepath.Join(out, "timezones.json"), f); err != nil {
return err
}
fmt.Printf("[timezones] %d\n", len(f.Timezones))
}
// emoji 分组(需登录)
if eg, err := api.MessagesGetEmojiGroups(ctx, 0); err != nil {
fmt.Fprintln(os.Stderr, "skip emoji_groups:", err)
} else if full, ok := eg.(*tg.MessagesEmojiGroups); ok {
var f emojiGroupsFile
for _, g := range full.Groups {
if gg, ok := g.(*tg.EmojiGroup); ok {
f.Groups = append(f.Groups, emojiGroupJSON{Title: gg.Title, IconEmojiID: gg.IconEmojiID, Emoticons: gg.Emoticons})
}
}
if err := writeJSON(filepath.Join(out, "emoji_groups.json"), f); err != nil {
return err
}
fmt.Printf("[emoji_groups] %d\n", len(f.Groups))
}
// emoji 关键词(需登录)
if kw, err := api.MessagesGetEmojiKeywords(ctx, lang); err != nil {
fmt.Fprintln(os.Stderr, "skip emoji_keywords:", err)
} else {
f := emojiKeywordsFile{LangCode: kw.LangCode, Version: kw.Version}
for _, k := range kw.Keywords {
if kk, ok := k.(*tg.EmojiKeyword); ok {
f.Keywords = append(f.Keywords, emojiKeywordJSON{Keyword: kk.Keyword, Emoticons: kk.Emoticons})
}
}
name := fmt.Sprintf("emoji_keywords_%s.json", f.LangCode)
if f.LangCode == "" {
name = "emoji_keywords_" + lang + ".json"
}
if err := writeJSON(filepath.Join(out, name), f); err != nil {
return err
}
fmt.Printf("[emoji_keywords %s] %d (v%d)\n", f.LangCode, len(f.Keywords), f.Version)
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}

294
cmd/giftcheck/main.go Normal file
View file

@ -0,0 +1,294 @@
// Command giftcheck 是 Star 礼物端到端验证工具(开发用,非生产组件)。
//
// 以用户身份登录本地 telesrv验证 star gift 全链路对 live 服务端:
// 1. payments.getStarGifts —— 打印礼物目录。
// 2. 从 dialogs 找一个收礼用户(或 -to 指定)。
// 3. payments.getPaymentForm(inputInvoiceStarGift) —— 必须返 paymentFormStarGiftXTR+非空 prices
// 4. payments.sendStarsForm —— 必须返 paymentResult{updates}(含礼物服务消息 + updateStarsBalance
// 5. payments.getStarsStatus 前后对比验证扣费。
//
// 用法: go run ./cmd/giftcheck -phone "+8618800000001"
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
phone := flag.String("phone", "", "登录手机号")
code := flag.String("code", "12345", "开发登录码")
toID := flag.Int64("to", 0, "收礼用户 id0=自动从 dialogs 找)")
toHash := flag.Int64("to-hash", 0, "收礼用户 access_hash")
flag.Parse()
if *phone == "" {
fmt.Fprintln(os.Stderr, "缺少 -phone")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, _ := net.SplitHostPort(*addr)
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash, Settings: tg.CodeSettings{}})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
sc, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode 类型 = %T", sent)
}
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: *phone, PhoneCodeHash: sc.PhoneCodeHash, PhoneCode: *code})
if err != nil {
return fmt.Errorf("signIn: %w", err)
}
self := authz.(*tg.AuthAuthorization).User.(*tg.User)
fmt.Printf("==== 登录: id=%d name=%q ====\n", self.ID, self.FirstName)
// 1. 目录。
gifts := giftCatalog(ctx, raw)
if len(gifts) == 0 {
return fmt.Errorf("礼物目录为空animated_emoji 未 seed")
}
gift := gifts[0]
fmt.Printf(" 目录 %d 个礼物;选第一个: id=%d stars=%d title=%q sticker=%T\n", len(gifts), gift.ID, gift.Stars, giftTitle(gift), gift.Sticker)
balBefore := starsBalance(ctx, raw, "扣费前")
// 2. 收礼用户(-to 指定 id 时从 dialogs 解析其 access_hash否则取首个 user
var to *tg.InputPeerUser
if *toID != 0 && *toHash != 0 {
to = &tg.InputPeerUser{UserID: *toID, AccessHash: *toHash}
} else {
to = findRecipient(ctx, raw, self.ID, *toID)
}
if to == nil {
return fmt.Errorf("找不到收礼用户(用 -to/-to-hash 指定)")
}
fmt.Printf(" 收礼用户: id=%d\n", to.UserID)
inv := &tg.InputInvoiceStarGift{Peer: to, GiftID: gift.ID}
// 3. getPaymentForm → paymentFormStarGift。
formRes, err := raw.PaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
return fmt.Errorf("getPaymentForm: %w", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok {
fmt.Printf(" [FAIL] getPaymentForm 返回 %Twant *PaymentsPaymentFormStarGiftTDesktop 单分支 match\n", formRes)
return nil
}
fmt.Printf(" getPaymentForm: paymentFormStarGift form_id=%d currency=%s prices=%d\n", form.FormID, form.Invoice.Currency, len(form.Invoice.Prices))
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) == 0 {
fmt.Printf(" [FAIL] invoice 须 XTR + 非空 prices\n")
return nil
}
// 4. sendStarsForm → paymentResult。
payRes, err := raw.PaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
return fmt.Errorf("sendStarsForm: %w", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
fmt.Printf(" [FAIL] sendStarsForm 返回 %Twant *PaymentsPaymentResultDrKLO 强转)\n", payRes)
return nil
}
inspectGiftUpdates(pay.Updates)
balAfter := starsBalance(ctx, raw, "扣费后")
fmt.Printf("==== 扣费: %d -> %d差 %d期望 -%d====\n", balBefore, balAfter, balBefore-balAfter, gift.Stars)
if balBefore-balAfter == gift.Stars {
fmt.Println("==== ✅ PASSstar gift 扣费正确 ====")
} else {
fmt.Println("==== ❌ FAIL扣费金额不符 ====")
}
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
}
func giftCatalog(ctx context.Context, raw *tg.Client) []*tg.StarGift {
res, err := raw.PaymentsGetStarGifts(ctx, 0)
if err != nil {
fmt.Printf(" getStarGifts 失败: %v\n", err)
return nil
}
full, ok := res.(*tg.PaymentsStarGifts)
if !ok {
fmt.Printf(" getStarGifts 返回 %T\n", res)
return nil
}
out := make([]*tg.StarGift, 0, len(full.Gifts))
for _, g := range full.Gifts {
if sg, ok := g.(*tg.StarGift); ok {
out = append(out, sg)
}
}
return out
}
func giftTitle(g *tg.StarGift) string {
t, _ := g.GetTitle()
return t
}
func starsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
return -1
}
var bal int64
if amt, ok := status.Balance.(*tg.StarsAmount); ok {
bal = amt.Amount
}
fmt.Printf(" [%s] 余额=%d stars\n", label, bal)
return bal
}
func findRecipient(ctx context.Context, raw *tg.Client, selfID, targetID int64) *tg.InputPeerUser {
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100})
if err != nil {
return nil
}
var users []tg.UserClass
switch v := dlgs.(type) {
case *tg.MessagesDialogs:
users = v.Users
case *tg.MessagesDialogsSlice:
users = v.Users
default:
return nil
}
for _, uc := range users {
u, ok := uc.(*tg.User)
if !ok || u.ID == selfID || u.Bot || u.Self {
continue
}
if targetID != 0 {
if u.ID == targetID {
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
}
continue
}
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
}
return nil
}
func inspectGiftUpdates(res tg.UpdatesClass) {
var ups []tg.UpdateClass
switch v := res.(type) {
case *tg.Updates:
ups = v.Updates
case *tg.UpdatesCombined:
ups = v.Updates
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
fmt.Printf(" [警告] paymentResult.updates 非 Updates 子类型: %T\n", res)
return
}
hasGiftMsg, hasBalance := false, false
for _, up := range ups {
switch u := up.(type) {
case *tg.UpdateNewMessage:
if svc, ok := u.Message.(*tg.MessageService); ok {
if a, ok := svc.Action.(*tg.MessageActionStarGift); ok {
hasGiftMsg = true
gid := int64(0)
if sg, ok := a.Gift.(*tg.StarGift); ok {
gid = sg.ID
}
fmt.Printf(" messageActionStarGift: msg_id=%d gift_id=%d\n", u.Message.(*tg.MessageService).ID, gid)
}
}
case *tg.UpdateStarsBalance:
hasBalance = true
if amt, ok := u.Balance.(*tg.StarsAmount); ok {
fmt.Printf(" updateStarsBalance: %d\n", amt.Amount)
}
}
}
fmt.Printf(" paymentResult: 含礼物服务消息=%v 含 updateStarsBalance=%v\n", hasGiftMsg, hasBalance)
}

127
cmd/langpackfetch/main.go Normal file
View file

@ -0,0 +1,127 @@
// Command langpackfetch 从官方 Telegram 拉取 langpack(免登公开方法 langpack.getLangPack),
// 转成 telesrv seed 的 .strings 格式(复数用 Key#one/#other 后缀)。api 凭据用 TDesktop
// 开源公开的 id/hash。
//
// 用法:
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage:\n langpackfetch languages [pack]\n langpackfetch <out_dir> <langCode> [pack...]")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if os.Args[1] == "languages" {
pack := "android"
if len(os.Args) > 2 {
pack = os.Args[2]
}
langs, err := api.LangpackGetLanguages(ctx, pack)
if err != nil {
return fmt.Errorf("getLanguages %q: %w", pack, err)
}
fmt.Printf("pack=%s, %d languages:\n", pack, len(langs))
for _, l := range langs {
fmt.Printf(" %-18s %-26s official=%-5v beta=%-5v strings=%-6d translated=%-6d base=%q\n",
l.LangCode, l.Name, l.Official, l.Beta, l.StringsCount, l.TranslatedCount, l.BaseLangCode)
}
return nil
}
outRoot := os.Args[1]
langCode := "en"
if len(os.Args) > 2 {
langCode = os.Args[2]
}
packs := os.Args[3:]
if len(packs) == 0 {
packs = []string{"android", "ios", "macos"}
}
for _, pack := range packs {
diff, err := api.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{
LangPack: pack,
LangCode: langCode,
})
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s/%s: %v\n", pack, langCode, err)
continue
}
if err := writePack(outRoot, pack, diff); err != nil {
return err
}
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func writePack(root, pack string, diff *tg.LangPackDifference) error {
dir := filepath.Join(root, pack)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
var b strings.Builder
count := 0
for _, s := range diff.Strings {
switch v := s.(type) {
case *tg.LangPackString:
fmt.Fprintf(&b, "%s = %s;\n", strconv.Quote(v.Key), strconv.Quote(v.Value))
count++
case *tg.LangPackStringPluralized:
emit := func(suffix, val string) {
if val != "" {
fmt.Fprintf(&b, "%s = %s;\n", strconv.Quote(v.Key+suffix), strconv.Quote(val))
}
}
emit("#zero", v.ZeroValue)
emit("#one", v.OneValue)
emit("#two", v.TwoValue)
emit("#few", v.FewValue)
emit("#many", v.ManyValue)
emit("#other", v.OtherValue)
count++
case *tg.LangPackStringDeleted:
// 跳过删除项
}
}
langCode := diff.LangCode
if langCode == "" {
langCode = "unknown"
}
name := fmt.Sprintf("%s_%s_v%d.strings", pack, langCode, diff.Version)
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (%d strings, version %d)\n", path, count, diff.Version)
return nil
}

304
cmd/starcheck/main.go Normal file
View file

@ -0,0 +1,304 @@
// Command starcheck 是 Stars 付费 reaction 端到端验证工具(开发用,非生产组件)。
//
// 它以用户身份(开发码 12345登录本地 telesrv验证 Stars 本地账本与付费 reaction
// 1. payments.getStarsStatus —— 打印当前余额Phase 1惰性首读授予后应 >0
// 2. 自动从 dialogs 找一个广播频道 + 其顶部消息(或用 -channel-id/-access-hash/-msg-id 指定)。
// 3. messages.sendPaidReaction —— 发付费 reaction打印返回的 Updates 结构
// Phase 2 崩溃约束:必须是 *tg.Updates应含 updateMessageReactions + updateStarsBalance
// 4. payments.getStarsStatus —— 再次打印余额,验证已扣 -count。
//
// 用法:
//
// go run ./cmd/starcheck -phone "+8618800000001" -count 50
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec {
return codec.NoHeader{Codec: codec.Intermediate{}}
})
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
phone := flag.String("phone", "", "登录手机号,如 +8618800000001")
code := flag.String("code", "12345", "开发登录码")
count := flag.Int("count", 50, "付费 reaction 星数")
channelID := flag.Int64("channel-id", 0, "指定频道 id0=自动从 dialogs 找广播频道)")
accessHash := flag.Int64("access-hash", 0, "指定频道 access_hash")
msgID := flag.Int("msg-id", 0, "指定消息 id0=用频道顶部消息)")
flag.Parse()
if *phone == "" {
fmt.Fprintln(os.Stderr, "缺少 -phone")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{
{ID: *dcID, IPAddress: host, Port: port, Static: true},
}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
// 1. 用户登录(开发码)。
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash,
Settings: tg.CodeSettings{},
})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
sentCode, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode 类型 = %T", sent)
}
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
PhoneNumber: *phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: *code,
})
if err != nil {
return fmt.Errorf("signIn: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("authorization 类型 = %T", authz)
}
self, _ := a.User.(*tg.User)
fmt.Printf("==== 登录成功: id=%d name=%q phone=%s ====\n", self.ID, self.FirstName, *phone)
// 2. 余额before
balBefore := printStarsBalance(ctx, raw, "扣费前")
// 3. 解析目标频道 + 消息。
var peer *tg.InputPeerChannel
mid := *msgID
if *channelID != 0 {
peer = &tg.InputPeerChannel{ChannelID: *channelID, AccessHash: *accessHash}
} else {
ch, topMsg, err := findBroadcastChannel(ctx, raw)
if err != nil {
return err
}
peer = &tg.InputPeerChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}
if mid == 0 {
mid = topMsg
}
fmt.Printf(" 目标频道: id=%d title=%q access_hash=%d top_msg=%d\n", ch.ID, ch.Title, ch.AccessHash, mid)
}
if mid == 0 {
return fmt.Errorf("无可用消息 id频道无消息用 -msg-id 指定)")
}
// 4. 发付费 reaction。
rid, _ := randInt64()
fmt.Printf(">> sendPaidReaction: channel=%d msg=%d count=%d\n", peer.ChannelID, mid, *count)
res, err := raw.MessagesSendPaidReaction(ctx, &tg.MessagesSendPaidReactionRequest{
Peer: peer, MsgID: mid, Count: *count, RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendPaidReaction: %w", err)
}
inspectPaidReactionUpdates(res)
// 5. 余额after
balAfter := printStarsBalance(ctx, raw, "扣费后")
fmt.Printf("==== 扣费校验: %d -> %d差 %d期望 -%d====\n", balBefore, balAfter, balBefore-balAfter, *count)
if balBefore-balAfter == int64(*count) {
fmt.Println("==== ✅ PASS付费 reaction 扣费正确 ====")
} else {
fmt.Println("==== ❌ FAIL扣费金额不符 ====")
}
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
}
func printStarsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
return -1
}
amount, _ := status.Balance.(*tg.StarsAmount)
var bal int64
if amount != nil {
bal = amount.Amount
}
fmt.Printf(" [%s] 余额=%d stars (balance 类型=%T, chats=%d users=%d)\n", label, bal, status.Balance, len(status.Chats), len(status.Users))
return bal
}
// findBroadcastChannel 从 dialogs 找第一个广播频道 + 其顶部消息 id。
func findBroadcastChannel(ctx context.Context, raw *tg.Client) (*tg.Channel, int, error) {
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100,
})
if err != nil {
return nil, 0, fmt.Errorf("getDialogs: %w", err)
}
var chats []tg.ChatClass
var dialogs []tg.DialogClass
switch v := dlgs.(type) {
case *tg.MessagesDialogs:
chats, dialogs = v.Chats, v.Dialogs
case *tg.MessagesDialogsSlice:
chats, dialogs = v.Chats, v.Dialogs
default:
return nil, 0, fmt.Errorf("dialogs 类型 = %T", dlgs)
}
topByChannel := make(map[int64]int)
for _, d := range dialogs {
dd, ok := d.(*tg.Dialog)
if !ok {
continue
}
if pc, ok := dd.Peer.(*tg.PeerChannel); ok {
topByChannel[pc.ChannelID] = dd.TopMessage
}
}
for _, c := range chats {
ch, ok := c.(*tg.Channel)
if !ok || !ch.Broadcast || ch.Megagroup {
continue
}
return ch, topByChannel[ch.ID], nil
}
return nil, 0, fmt.Errorf("dialogs 中无广播频道")
}
// inspectPaidReactionUpdates 检查返回的 Updates 是否合法且含 updateMessageReactions/updateStarsBalance。
func inspectPaidReactionUpdates(res tg.UpdatesClass) {
fmt.Printf(" 返回类型=%T\n", res)
var ups []tg.UpdateClass
switch v := res.(type) {
case *tg.Updates:
ups = v.Updates
case *tg.UpdatesCombined:
ups = v.Updates
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
fmt.Printf(" [警告] 返回非 Updates 子类型DrKLO 会 ClassCastException 崩溃)\n")
return
}
hasReactions, hasBalance := false, false
for _, up := range ups {
switch u := up.(type) {
case *tg.UpdateMessageReactions:
hasReactions = true
paid := 0
for _, rc := range u.Reactions.Results {
if _, ok := rc.Reaction.(*tg.ReactionPaid); ok {
paid = rc.Count
}
}
reactors, _ := u.Reactions.GetTopReactors()
fmt.Printf(" updateMessageReactions: msg=%d paid_count=%d top_reactors=%d\n", u.MsgID, paid, len(reactors))
case *tg.UpdateStarsBalance:
hasBalance = true
amount, _ := u.Balance.(*tg.StarsAmount)
var b int64
if amount != nil {
b = amount.Amount
}
fmt.Printf(" updateStarsBalance: balance=%d\n", b)
}
}
fmt.Printf(" 合法 Updates=true, 含 updateMessageReactions=%v, 含 updateStarsBalance=%v\n", hasReactions, hasBalance)
}
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

385
cmd/stickerfetch/main.go Normal file
View file

@ -0,0 +1,385 @@
// Command stickerfetch 从官方 Telegram 拉取贴纸集(messages.getStickerSet),写成 telesrv
// SeedMedia 可直接导入的 seed 目录格式:<out>/<setdir>/set_info.json + stickers/<docid>.tgs。
// 这样 masks / custom-emoji / 系统集等只需把 seed 文件放进 data/sticker-seed 即可被现有
// SeedMedia + handler 识别下发,无需改服务端代码。
//
// 支持的 set spec(命令行参数,可多个):
// short:<shortName> 按 short_name 拉(普通/mask/emoji 集)
// emoji_default_statuses inputStickerSetEmojiDefaultStatuses
// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses
// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons
//
// 需登录会话(复用 appearancefetch 的 /tmp/appearance.session,SESSION env 可覆盖)。
//
// 用法: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]
package main
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
func specToInput(spec string) (tg.InputStickerSetClass, string, error) {
switch {
case strings.HasPrefix(spec, "short:"):
name := strings.TrimPrefix(spec, "short:")
return &tg.InputStickerSetShortName{ShortName: name}, name, nil
case spec == "emoji_default_statuses":
return &tg.InputStickerSetEmojiDefaultStatuses{}, "EmojiDefaultStatuses", nil
case spec == "emoji_channel_default_statuses":
return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil
case spec == "emoji_default_topic_icons":
return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil
default:
return nil, "", fmt.Errorf("unknown spec %q", spec)
}
}
// ---- seed JSON 结构(字段对齐 internal/app/files/seed.go 的解析) ----
type attrJSON struct {
Type string `json:"_"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Alt string `json:"alt,omitempty"`
Mask bool `json:"mask,omitempty"`
Free bool `json:"free,omitempty"`
TextColor bool `json:"text_color,omitempty"`
FileName string `json:"file_name,omitempty"`
Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"`
Voice bool `json:"voice,omitempty"`
Title string `json:"title,omitempty"`
Performer string `json:"performer,omitempty"`
Stickerset *inputSetRefJSON `json:"stickerset,omitempty"`
}
type inputSetRefJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
}
type documentJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
FileReference string `json:"file_reference"`
Date string `json:"date"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
DCID int `json:"dc_id"`
Attributes []attrJSON `json:"attributes"`
}
type packJSON struct {
Emoticon string `json:"emoticon"`
Documents []int64 `json:"documents"`
}
type setJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
Title string `json:"title"`
ShortName string `json:"short_name"`
Count int `json:"count"`
Hash int `json:"hash"`
Official bool `json:"official"`
Masks bool `json:"masks"`
Emojis bool `json:"emojis"`
}
type resultJSON struct {
Type string `json:"_"`
Set setJSON `json:"set"`
Packs []packJSON `json:"packs"`
Documents []documentJSON `json:"documents"`
}
type fileJSON struct {
APICall string `json:"api_call"`
InputSetType string `json:"input_set_type"`
Result resultJSON `json:"result"`
}
func mapAttrs(in []tg.DocumentAttributeClass) []attrJSON {
out := make([]attrJSON, 0, len(in))
for _, a := range in {
switch v := a.(type) {
case *tg.DocumentAttributeImageSize:
out = append(out, attrJSON{Type: "DocumentAttributeImageSize", W: v.W, H: v.H})
case *tg.DocumentAttributeAnimated:
out = append(out, attrJSON{Type: "DocumentAttributeAnimated"})
case *tg.DocumentAttributeSticker:
aj := attrJSON{Type: "DocumentAttributeSticker", Alt: v.Alt, Mask: v.Mask}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeCustomEmoji:
aj := attrJSON{Type: "DocumentAttributeCustomEmoji", Alt: v.Alt, Free: v.Free, TextColor: v.TextColor}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeVideo:
out = append(out, attrJSON{Type: "DocumentAttributeVideo", W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
case *tg.DocumentAttributeFilename:
out = append(out, attrJSON{Type: "DocumentAttributeFilename", FileName: v.FileName})
}
}
return out
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]\n spec: short:<name> | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons")
os.Exit(2)
}
out := os.Args[1]
specs := os.Args[2:]
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if status, err := client.Auth().Status(ctx); err != nil || !status.Authorized {
return fmt.Errorf("session %s 未授权(先用 appearancefetch 登录): %v", sessionPath(), err)
}
dl := downloader.NewDownloader()
for _, spec := range specs {
var err error
if spec == "effects" {
err = fetchEffects(ctx, api, dl, out)
} else {
err = fetchSet(ctx, api, dl, out, spec)
}
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", spec, err)
}
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot, spec string) error {
input, label, err := specToInput(spec)
if err != nil {
return err
}
res, err := api.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: input, Hash: 0})
if err != nil {
return fmt.Errorf("getStickerSet: %w", err)
}
full, ok := res.(*tg.MessagesStickerSet)
if !ok {
return fmt.Errorf("got %T, want messagesStickerSet", res)
}
setName := full.Set.ShortName
if setName == "" {
setName = label
}
setDir := filepath.Join(outRoot, fmt.Sprintf("%s_%d", setName, full.Set.ID))
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
return err
}
docs := make([]documentJSON, 0, len(full.Documents))
for _, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
// 下载主体 blob → stickers/<docid>.<ext>
ext := ".tgs"
if doc.MimeType == "video/webm" {
ext = ".webm"
} else if doc.MimeType == "image/webp" {
ext = ".webp"
}
path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext))
f, err := os.Create(path)
if err != nil {
return err
}
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if _, err := dl.Download(api, loc).Stream(ctx, f); err != nil {
f.Close()
return fmt.Errorf("download doc %d: %w", doc.ID, err)
}
f.Close()
docs = append(docs, documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
})
}
packs := make([]packJSON, 0, len(full.Packs))
for _, p := range full.Packs {
packs = append(packs, packJSON{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.Documents...)})
}
out := fileJSON{
APICall: "messages.getStickerSet",
InputSetType: fmt.Sprintf("%T", input),
Result: resultJSON{
Type: "StickerSet",
Set: setJSON{
ID: full.Set.ID,
AccessHash: full.Set.AccessHash,
Title: full.Set.Title,
ShortName: full.Set.ShortName,
Count: full.Set.Count,
Hash: full.Set.Hash,
Official: full.Set.Official,
Masks: full.Set.Masks,
Emojis: full.Set.Emojis,
},
Packs: packs,
Documents: docs,
},
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[%s] set=%q id=%d docs=%d masks=%v emojis=%v\n", spec, setName, full.Set.ID, len(docs), full.Set.Masks, full.Set.Emojis)
return nil
}
// ---- 消息特效(messages.getAvailableEffects)----
type effectJSON struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
PremiumRequired bool `json:"premium_required,omitempty"`
}
type effectsFileJSON struct {
APICall string `json:"api_call"`
Result struct {
Effects []effectJSON `json:"effects"`
Documents []documentJSON `json:"documents"`
} `json:"result"`
}
// downloadDoc 下载一个文档主体到 dir/<docid>.<ext> 并返回其 seed 元数据。
func downloadDoc(ctx context.Context, api *tg.Client, dl *downloader.Downloader, dir string, doc *tg.Document) (documentJSON, error) {
ext := ".tgs"
switch doc.MimeType {
case "video/webm":
ext = ".webm"
case "image/webp":
ext = ".webp"
}
path := filepath.Join(dir, fmt.Sprintf("%d%s", doc.ID, ext))
f, err := os.Create(path)
if err != nil {
return documentJSON{}, err
}
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if _, err := dl.Download(api, loc).Stream(ctx, f); err != nil {
f.Close()
return documentJSON{}, fmt.Errorf("download doc %d: %w", doc.ID, err)
}
f.Close()
return documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
}, nil
}
func fetchEffects(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot string) error {
res, err := api.MessagesGetAvailableEffects(ctx, 0)
if err != nil {
return fmt.Errorf("getAvailableEffects: %w", err)
}
full, ok := res.(*tg.MessagesAvailableEffects)
if !ok {
return fmt.Errorf("got %T, want messagesAvailableEffects", res)
}
dir := filepath.Join(outRoot, "telegram_effects_export")
docsDir := filepath.Join(dir, "documents")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
var out effectsFileJSON
out.APICall = "messages.getAvailableEffects"
for _, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
dj, err := downloadDoc(ctx, api, dl, docsDir, doc)
if err != nil {
return err
}
out.Result.Documents = append(out.Result.Documents, dj)
}
for _, e := range full.Effects {
ej := effectJSON{ID: e.ID, Emoticon: e.Emoticon, EffectStickerID: e.EffectStickerID, PremiumRequired: e.PremiumRequired}
if v, ok := e.GetStaticIconID(); ok {
ej.StaticIconID = v
}
if v, ok := e.GetEffectAnimationID(); ok {
ej.EffectAnimationID = v
}
out.Result.Effects = append(out.Result.Effects, ej)
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "effects.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[effects] effects=%d documents=%d\n", len(out.Result.Effects), len(out.Result.Documents))
return nil
}

118
cmd/telesrv-admin/main.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg, err := loadConfig()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("connect postgres: %w", err)
}
defer pool.Close()
srv, err := newServer(cfg, newReadStore(pool))
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}()
log.Printf("telesrv-admin listening on %s", cfg.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
type uiConfig struct {
Addr string
PostgresDSN string
AdminAPIURL string
AdminAPIToken string
Password string
Token string
SessionKey []byte
}
func loadConfig() (uiConfig, error) {
cfg := uiConfig{
Addr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable"),
AdminAPIURL: adminAPIURL(envOr("TELESRV_ADMIN_API_ADDR", "127.0.0.1:2399")),
AdminAPIToken: os.Getenv("TELESRV_ADMIN_API_TOKEN"),
Password: os.Getenv("TELESRV_ADMIN_UI_PASSWORD"),
Token: os.Getenv("TELESRV_ADMIN_UI_TOKEN"),
}
if cfg.Password == "" && cfg.Token == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is required")
}
if strings.TrimSpace(cfg.AdminAPIToken) == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_API_TOKEN is required for admin write actions")
}
rawKey := os.Getenv("TELESRV_ADMIN_SESSION_KEY")
if rawKey == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_SESSION_KEY is required")
}
sum := sha256.Sum256([]byte(rawKey))
cfg.SessionKey = sum[:]
return cfg, nil
}
func adminAPIURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
addr = "127.0.0.1:2399"
}
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
return strings.TrimRight(addr, "/")
}
return "http://" + addr
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func newCommandID(prefix string) string {
var b [6]byte
_, _ = rand.Read(b[:])
return prefix + "-" + time.Now().UTC().Format("20060102T150405.000000000") + "-" + hex.EncodeToString(b[:])
}

View file

@ -0,0 +1,766 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
accountSearchLimit = 20
accountListDefaultLimit = 50
accountListMaxLimit = 100
channelSearchLimit = 50
channelListDefaultLimit = 50
channelListMaxLimit = 100
messagePageLimit = 100
)
type readStore struct {
pool *pgxpool.Pool
}
func newReadStore(pool *pgxpool.Pool) *readStore {
return &readStore{pool: pool}
}
type AccountRow struct {
ID int64
Phone string
Username string
FirstName string
LastName string
CreatedAt time.Time
UpdatedAt time.Time
Frozen bool
Reason string
Verified bool
PremiumUntil int64
LastActiveAt time.Time
DeviceCount int
}
type AccountDetail struct {
Account AccountRow
About string
LastSeenAt int64
Verified bool
Support bool
Bot bool
Restriction RestrictionRow
HasRestriction bool
Authorizations []AuthorizationRow
AuditLogs []AuditLogRow
}
type RestrictionRow struct {
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt time.Time
}
type AuthorizationRow struct {
AuthKeyID int64
Hash int64
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
IP string
PasswordPending bool
CreatedAt time.Time
ActiveAt time.Time
}
type AuditLogRow struct {
ID int64
CommandID string
Actor string
Action string
DryRun bool
Reason string
Status string
Error string
Result string
CreatedAt time.Time
}
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
}
type ChannelDetail struct {
Channel ChannelRow
ChannelJSON string
AuditLogs []AuditLogRow
}
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, 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
}
phone := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+")
phoneRaw := strings.TrimSpace(q)
username := strings.ToLower(strings.TrimPrefix(q, "@"))
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
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(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
FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_id = u.id
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
ORDER BY u.id
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
if err != nil {
return nil, fmt.Errorf("search accounts: %w", err)
}
defer rows.Close()
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); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, 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 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.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
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND (
c.id = $1
OR lower(COALESCE(c.username, '')) = $2
OR p.username_lower = $2
OR lower(c.title) LIKE '%' || $2 || '%'
)
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, id, username, channelSearchLimit)
if err != nil {
return nil, fmt.Errorf("search channels: %w", err)
}
defer rows.Close()
return scanChannelRows(rows)
}
func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID int64, limit int) ([]ChannelRow, bool, error) {
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
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.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
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND ($1::bigint = 0 OR (c.updated_at, c.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, beforeUpdatedUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list channels: %w", err)
}
defer rows.Close()
out, err := scanChannelRows(rows)
if err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (ChannelDetail, error) {
var out ChannelDetail
var raw []byte
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.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
FROM channels c
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE c.id = $1
AND NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)`, channelID).Scan(channelScanDestWithRaw(&out.Channel, &raw)...)
if err != nil {
return out, fmt.Errorf("get channel: %w", err)
}
out.ChannelJSON = prettyJSON(raw)
out.AuditLogs, err = s.channelAuditLogs(ctx, channelID)
if err != nil {
return out, err
}
return out, nil
}
type channelScanner interface {
Scan(dest ...any) error
}
func scanChannelRows(rows pgx.Rows) ([]ChannelRow, error) {
out := make([]ChannelRow, 0)
for rows.Next() {
var item ChannelRow
if err := scanChannelRow(rows, &item); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func scanChannelRow(row channelScanner, item *ChannelRow) error {
return row.Scan(channelScanDest(item)...)
}
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.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount,
&item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt,
}
}
func channelScanDestWithRaw(item *ChannelRow, raw *[]byte) []any {
dest := channelScanDest(item)
return append(dest, raw)
}
func (s *readStore) ListAccounts(ctx context.Context, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
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(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
FROM users u
JOIN auth ON auth.user_id = u.id
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY auth.last_active_at DESC, u.id DESC
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list accounts: %w", err)
}
defer rows.Close()
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); err != nil {
return nil, false, err
}
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) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
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,
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_send_restrictions r ON r.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.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username,
)
if err != nil {
return out, fmt.Errorf("get account: %w", err)
}
out.Restriction, out.HasRestriction, err = s.restriction(ctx, userID)
if err != nil {
return out, err
}
out.Authorizations, err = s.authorizations(ctx, userID)
if err != nil {
return out, err
}
out.AuditLogs, err = s.auditLogs(ctx, userID)
if err != nil {
return out, err
}
return out, nil
}
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
var r RestrictionRow
err := s.pool.QueryRow(ctx, `
SELECT frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return RestrictionRow{}, false, nil
}
return RestrictionRow{}, false, fmt.Errorf("get restriction: %w", err)
}
return r, true, nil
}
func (s *readStore) authorizations(ctx context.Context, userID int64) ([]AuthorizationRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT auth_key_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, created_at DESC
LIMIT 100`, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations: %w", err)
}
defer rows.Close()
out := make([]AuthorizationRow, 0)
for rows.Next() {
var a AuthorizationRow
if err := rows.Scan(&a.AuthKeyID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) auditLogs(ctx context.Context, userID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_user_id = $1
ORDER BY id DESC
LIMIT 30`, userID)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) channelAuditLogs(ctx context.Context, channelID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_peer_type = 'channel' AND target_peer_id = $1
ORDER BY id DESC
LIMIT 30`, channelID)
if err != nil {
return nil, fmt.Errorf("list channel audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
type MessageRow struct {
OwnerUserID int64
BoxID int
PrivateMessageID int64
MessageSenderID int64
PeerID int64
FromUserID int64
Date int64
Outgoing bool
Body string
PTS int
Deleted bool
Media string
}
type GroupMessageRow struct {
ChannelID int64
ID int
SenderUserID int64
FromPeerType string
FromPeerID int64
Date int64
Post bool
Body string
PTS int
Deleted bool
Media string
ViewsCount int
EditDate int
Pinned bool
}
func (s *readStore) ListMessages(ctx context.Context, ownerUserID, peerID int64, beforeDate int64, beforeID int, limit int) ([]MessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb)
FROM message_boxes
WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2
AND ($3::bigint = 0 OR (message_date, box_id) < ($3::bigint, $4::int))
ORDER BY message_date DESC, box_id DESC
LIMIT $5`, ownerUserID, peerID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
defer rows.Close()
out := make([]MessageRow, 0)
for rows.Next() {
var item MessageRow
var media []byte
if err := rows.Scan(&item.OwnerUserID, &item.BoxID, &item.PrivateMessageID, &item.MessageSenderID, &item.PeerID, &item.FromUserID, &item.Date, &item.Outgoing, &item.Body, &item.PTS, &item.Deleted, &media); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) ListGroupMessages(ctx context.Context, channelID int64, beforeDate int64, beforeID int, limit int) ([]GroupMessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted
AND ($2::bigint = 0 OR (message_date, id) < ($2::bigint, $3::int))
ORDER BY message_date DESC, id DESC
LIMIT $4`, channelID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list group messages: %w", err)
}
defer rows.Close()
out := make([]GroupMessageRow, 0)
for rows.Next() {
var item GroupMessageRow
var media []byte
if err := rows.Scan(&item.ChannelID, &item.ID, &item.SenderUserID, &item.FromPeerType, &item.FromPeerID, &item.Date, &item.Post, &item.Body, &item.PTS, &item.Deleted, &media, &item.ViewsCount, &item.EditDate, &item.Pinned); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
type MessageDetail struct {
Message MessageRow
MessageJSON string
DialogJSON string
PrivateJSON string
UpdateEvents []UpdateEventRow
Outbox []OutboxRow
}
type GroupMessageDetail struct {
Message GroupMessageRow
MessageJSON string
ChannelJSON string
UpdateEvents []ChannelUpdateEventRow
}
type UpdateEventRow struct {
PTS int
PTSCount int
Type string
Date int64
JSON string
}
type ChannelUpdateEventRow struct {
PTS int
PTSCount int
Type string
MessageID int
Date int64
SenderUserID int64
JSON string
}
type OutboxRow struct {
ID int64
TargetUserID int64
PTS int
EventType string
Status string
Attempts int
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *readStore) MessageDetail(ctx context.Context, ownerUserID int64, msgID int) (MessageDetail, error) {
var out MessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb), row_to_json(mb)::jsonb
FROM message_boxes mb
WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, msgID).Scan(
&out.Message.OwnerUserID, &out.Message.BoxID, &out.Message.PrivateMessageID, &out.Message.MessageSenderID, &out.Message.PeerID,
&out.Message.FromUserID, &out.Message.Date, &out.Message.Outgoing, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted,
&media, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.DialogJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(d)::jsonb FROM dialogs d WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerUserID, out.Message.PeerID)
out.PrivateJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(pm)::jsonb FROM private_messages pm WHERE id = $1`, out.Message.PrivateMessageID)
events, err := s.updateEvents(ctx, ownerUserID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
outbox, err := s.outbox(ctx, ownerUserID, out.Message.PTS)
if err != nil {
return out, err
}
out.Outbox = outbox
return out, nil
}
func (s *readStore) GroupMessageDetail(ctx context.Context, channelID int64, msgID int) (GroupMessageDetail, error) {
var out GroupMessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned, row_to_json(cm)::jsonb
FROM channel_messages cm
WHERE channel_id = $1 AND id = $2`, channelID, msgID).Scan(
&out.Message.ChannelID, &out.Message.ID, &out.Message.SenderUserID, &out.Message.FromPeerType, &out.Message.FromPeerID,
&out.Message.Date, &out.Message.Post, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media,
&out.Message.ViewsCount, &out.Message.EditDate, &out.Message.Pinned, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get group message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.ChannelJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(c)::jsonb FROM channels c WHERE id = $1`, channelID)
events, err := s.channelUpdateEvents(ctx, channelID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
return out, nil
}
func (s *readStore) rowJSON(ctx context.Context, sql string, args ...any) (string, error) {
var raw []byte
if err := s.pool.QueryRow(ctx, sql, args...).Scan(&raw); err != nil {
return "", err
}
return prettyJSON(raw), nil
}
func (s *readStore) updateEvents(ctx context.Context, ownerUserID int64, msgID int) ([]UpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, date, row_to_json(e)::jsonb
FROM user_update_events e
WHERE user_id = $1 AND (
message_box_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, ownerUserID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list update events: %w", err)
}
defer rows.Close()
out := make([]UpdateEventRow, 0)
for rows.Next() {
var e UpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.Date, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) channelUpdateEvents(ctx context.Context, channelID int64, msgID int) ([]ChannelUpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, message_id, date, sender_user_id, row_to_json(e)::jsonb
FROM channel_update_events e
WHERE channel_id = $1 AND (
message_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, channelID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list channel update events: %w", err)
}
defer rows.Close()
out := make([]ChannelUpdateEventRow, 0)
for rows.Next() {
var e ChannelUpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.MessageID, &e.Date, &e.SenderUserID, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) outbox(ctx context.Context, targetUserID int64, pts int) ([]OutboxRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, target_user_id, pts, event_type, status, attempts, created_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = $1 AND pts = $2
ORDER BY id DESC
LIMIT 20`, targetUserID, pts)
if err != nil {
return nil, fmt.Errorf("list outbox: %w", err)
}
defer rows.Close()
out := make([]OutboxRow, 0)
for rows.Next() {
var row OutboxRow
if err := rows.Scan(&row.ID, &row.TargetUserID, &row.PTS, &row.EventType, &row.Status, &row.Attempts, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func prettyJSON(raw []byte) string {
if len(raw) == 0 {
return "{}"
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
return string(out)
}

711
cmd/telesrv-admin/server.go Normal file
View file

@ -0,0 +1,711 @@
package main
import (
"bytes"
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
//go:embed web/dist
var webDist embed.FS
type server struct {
cfg uiConfig
read *readStore
web fs.FS
webServer http.Handler
}
func newServer(cfg uiConfig, read *readStore) (*server, error) {
web, err := fs.Sub(webDist, "web/dist")
if err != nil {
return nil, err
}
return &server{
cfg: cfg,
read: read,
web: web,
webServer: http.FileServer(http.FS(web)),
}, nil
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleAPILogin)
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
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/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)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
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)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
mux.HandleFunc("/", s.handleApp)
return mux
}
type actorKey struct{}
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, claims.Actor)))
})
}
func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor
}
return "admin"
}
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
clean := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if clean != "." && clean != "" {
if info, err := fs.Stat(s.web, clean); err == nil && !info.IsDir() {
s.webServer.ServeHTTP(w, r)
return
}
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
s.webServer.ServeHTTP(w, r2)
}
type loginRequest struct {
Secret string `json:"secret"`
}
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !s.validSecret(req.Secret) {
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return
}
value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin",
Exp: time.Now().Add(12 * time.Hour).Unix(),
Nonce: newCommandID("sess"),
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: int((12 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"actor": "admin"})
}
func (s *server) validSecret(secret string) bool {
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
return true
}
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
return true
}
return false
}
func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
}
func (s *server) handleAccountsAPI(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"))
beforeActiveUS, _ := parseInt64(r.URL.Query().Get("before_active_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []AccountRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchAccounts(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeActiveUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeActiveUS = last.LastActiveAt.UnixMicro()
}
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,
"next_before_active_us": nextBeforeActiveUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
userID, err := parseInt64(r.PathValue("id"))
if err != nil || userID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.AccountDetail(r.Context(), userID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleChannelsAPI(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"))
beforeUpdatedUS, _ := parseInt64(r.URL.Query().Get("before_updated_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []ChannelRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchChannels(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListChannels(r.Context(), beforeUpdatedUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeUpdatedUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeUpdatedUS = last.UpdatedAt.UnixMicro()
}
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"next_before_updated_us": nextBeforeUpdatedUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleChannelDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err := parseInt64(r.PathValue("id"))
if err != nil || channelID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.ChannelDetail(r.Context(), channelID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
owner, _ := parseInt64(q.Get("owner_user_id"))
peer, _ := parseInt64(q.Get("peer_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []MessageRow{}
var err error
if owner > 0 && peer > 0 {
rows, err = s.read.ListMessages(r.Context(), owner, peer, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"owner_user_id": owner,
"peer_id": peer,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
owner, err1 := parseInt64(r.URL.Query().Get("owner_user_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || owner <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner/msg_id")
return
}
detail, err := s.read.MessageDetail(r.Context(), owner, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleGroupMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
channelID, _ := parseInt64(q.Get("channel_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []GroupMessageRow{}
var err error
if channelID > 0 {
rows, err = s.read.ListGroupMessages(r.Context(), channelID, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"channel_id": channelID,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err1 := parseInt64(r.URL.Query().Get("channel_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || channelID <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid channel_id/msg_id")
return
}
detail, err := s.read.GroupMessageDetail(r.Context(), channelID, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
type freezeSendAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
}
func (s *server) handleFreezeSendAPI(w http.ResponseWriter, r *http.Request) {
var body freezeSendAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSendFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"),
UserID: body.UserID,
Frozen: body.Frozen,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/freeze-send", req)
writeCommandResultAPI(w, result, err)
}
type grantPremiumAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Months int `json:"months"`
}
func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
var body grantPremiumAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GrantPremiumRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-premium"),
UserID: body.UserID,
Months: body.Months,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-premium", req)
writeCommandResultAPI(w, result, err)
}
type setVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verified"),
UserID: body.UserID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type setChannelVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetChannelVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-verified"),
ChannelID: body.ChannelID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type revokeSessionsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Hash int64 `json:"hash"`
KeepHash int64 `json:"keep_hash"`
RevokeAll bool `json:"revoke_all"`
}
func (s *server) handleRevokeSessionsAPI(w http.ResponseWriter, r *http.Request) {
var body revokeSessionsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RevokeSessionsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-sessions"),
UserID: body.UserID,
Hash: body.Hash,
KeepHash: body.KeepHash,
RevokeAll: body.RevokeAll,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/revoke-sessions", req)
writeCommandResultAPI(w, result, err)
}
type deleteMessagesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
IDs []int `json:"ids"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteMessagesAPI(w http.ResponseWriter, r *http.Request) {
var body deleteMessagesAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateMessagesRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-messages"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
IDs: body.IDs,
Revoke: body.Revoke,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete", req)
writeCommandResultAPI(w, result, err)
}
type deleteHistoryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
MaxID int `json:"max_id"`
MinDate int `json:"min_date"`
MaxDate int `json:"max_date"`
MaxBatches int `json:"max_batches"`
JustClear bool `json:"just_clear"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request) {
var body deleteHistoryAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateHistoryRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-history"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
MaxID: body.MaxID,
MinDate: body.MinDate,
MaxDate: body.MaxDate,
JustClear: body.JustClear,
Revoke: body.Revoke,
MaxBatches: body.MaxBatches,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete-history", 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-") {
commandID = ""
}
dryRun := !confirm
if commandID == "" {
scope := "dry"
if !dryRun {
scope = "exec"
}
commandID = newCommandID(scope + "-" + prefix)
}
return admin.CommandMeta{
CommandID: commandID,
Actor: actorFromContext(r.Context()),
Reason: reason,
DryRun: dryRun,
}
}
func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any) (admin.CommandResult, error) {
body, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := decodeJSON(r, dst); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return false
}
return true
}
func decodeJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
return nil
}
func writeCommandResultAPI(w http.ResponseWriter, result admin.CommandResult, err error) {
if err != nil {
if result.Status == "" {
result.Status = "failed"
}
if result.Message == "" {
result.Message = "command failed"
}
if result.Error == "" {
result.Error = err.Error()
}
writeJSON(w, http.StatusBadGateway, result)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func parseInt64(v string) (int64, error) {
v = strings.TrimSpace(v)
if v == "" {
return 0, nil
}
return strconv.ParseInt(v, 10, 64)
}
func parseInt(v string) (int, error) {
n, err := parseInt64(v)
return int(n), err
}
func boolValue(v bool) string {
if v {
return "1"
}
return "0"
}
func displayPhone(v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "+") {
return v
}
for _, r := range v {
if r < '0' || r > '9' {
return v
}
}
return "+" + v
}
func channelKind(ch ChannelRow) string {
if ch.Broadcast && !ch.Megagroup {
return "频道"
}
if ch.Megagroup {
if ch.Forum {
return "超级群/论坛"
}
return "超级群"
}
return "频道/群"
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}

View file

@ -0,0 +1,68 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
)
const sessionCookieName = "telesrv_admin_session"
type sessionClaims struct {
Actor string `json:"actor"`
Exp int64 `json:"exp"`
Nonce string `json:"nonce"`
}
func signSession(key []byte, claims sessionClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encPayload := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(encPayload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return encPayload + "." + sig, nil
}
func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool) {
parts := strings.Split(value, ".")
if len(parts) != 2 {
return sessionClaims{}, false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(parts[0]))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(want)) != 1 {
return sessionClaims{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return sessionClaims{}, false
}
var claims sessionClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return sessionClaims{}, false
}
if claims.Actor == "" || claims.Exp <= now.Unix() {
return sessionClaims{}, false
}
return claims, true
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}

View file

@ -0,0 +1,44 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSignedSessionRoundTripAndTamper(t *testing.T) {
key := []byte("01234567890123456789012345678901")
now := time.Unix(1_700_000_000, 0)
value, err := signSession(key, sessionClaims{Actor: "admin", Exp: now.Add(time.Hour).Unix(), Nonce: "n"})
if err != nil {
t.Fatalf("signSession: %v", err)
}
claims, ok := verifySession(key, value, now)
if !ok || claims.Actor != "admin" {
t.Fatalf("verify ok=%v claims=%+v", ok, claims)
}
if _, ok := verifySession(key, value+"x", now); ok {
t.Fatal("tampered session verified")
}
if _, ok := verifySession(key, value, now.Add(2*time.Hour)); ok {
t.Fatal("expired session verified")
}
}
func TestSPAFallbackSmoke(t *testing.T) {
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/accounts", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `<div id="root"></div>`) {
t.Fatalf("spa body missing root: %s", rec.Body.String())
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
cmd/telesrv-admin/web/dist/index.html vendored Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-m3onrdER.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IDEWWIS9.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1017
cmd/telesrv-admin/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "telesrv-admin-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.3",
"typescript": "^5.6.3",
"vite": "^8.1.0"
}
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { api, APIError } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
export function App() {
const [actor, setActor] = useState<string | null | undefined>(undefined);
const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => {
const onPopState = () => setRoute(currentRoute());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
api.session()
.then((session) => setActor(session.actor))
.catch((error) => {
if (error instanceof APIError && error.status === 401) {
setActor(null);
return;
}
setActor(null);
});
}, []);
const navigate = (href: string) => {
window.history.pushState(null, "", href);
setRoute(currentRoute());
};
if (actor === undefined) {
return <BootScreen />;
}
if (actor === null) {
return <LoginPage onLogin={setActor} />;
}
return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
);
}

View file

@ -0,0 +1,72 @@
import type {
AccountDetail,
AccountListResponse,
ChannelDetail,
ChannelListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse
} from "./types";
export class APIError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
},
...init
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
const message = data?.error || data?.Error || data?.message || response.statusText;
throw new APIError(response.status, message);
}
return data as T;
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export const api = {
session: () => request<{ actor: string }>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
}),
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
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}`),
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) });
return request<MessageDetail>(`/api/messages/detail?${params.toString()}`);
},
groupMessages: (params: URLSearchParams) => request<GroupMessageListResponse>(`/api/messages/groups?${params.toString()}`),
groupMessage: (channelID: number, msgID: number) => {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)
})
};

View file

@ -0,0 +1,146 @@
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
type ActionTone = "neutral" | "warn" | "danger";
export function ActionButton({
label,
path,
payload,
icon,
compact = false,
tone = "danger",
onDone
}: {
label: string;
path: string;
payload: () => Record<string, unknown>;
icon?: ReactNode;
compact?: boolean;
tone?: ActionTone;
onDone?: () => void;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
function reset() {
setReason("");
setResult(null);
setError("");
}
async function run(confirm: boolean) {
if (!reason.trim()) {
setError("请填写操作原因");
return;
}
setBusy(true);
setError("");
try {
const body = { ...payload(), reason, confirm };
const commandResult = await api.action(path, body);
setResult(commandResult);
if (confirm) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
const canConfirm = result?.dry_run && !result.error;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
const previewPayload = useMemo(() => {
try {
return payload();
} catch (err) {
return { payload_error: errorMessage(err) };
}
}, [open, payload]);
return (
<>
<button
className={triggerClass}
type="button"
onClick={() => {
reset();
setOpen(true);
}}
>
{icon}
{label}
</button>
{open && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head">
<div>
<div className="eyebrow"></div>
<h2>{label}</h2>
</div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label="关闭"><X size={15} /></button>
</div>
<div className="command-body">
<div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong></strong>
</div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong></strong>
</div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong></strong>
</div>
</div>
<label className="form-field">
<span></span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" />
</label>
<div className="command-preview">
<div className="preview-head"><FileJson size={14} /> </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 || "操作结果"}</strong>
</div>
<div className="result-line"><span> ID</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span></span><strong>{result.status}</strong></div>
<div className="result-line"><span></span><strong>{result.dry_run ? "是" : "否"}</strong></div>
<div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}></button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? "重新预演" : "先预演"}
</button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} />
</button>
</div>
</section>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { Navigate } from "../routing";
export function AppLink({
href,
navigate,
className,
children
}: {
href: string;
navigate: Navigate;
className?: string;
children: ReactNode;
}) {
return (
<a
className={className}
href={href}
onClick={(event) => {
event.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}

View file

@ -0,0 +1,82 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format";
import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => {
setRemovedHashes(new Set());
}, [userID]);
const visibleRows = useMemo(
() => rows.filter((row) => !removedHashes.has(row.Hash)),
[rows, removedHashes]
);
function afterRevoke(mutator: (previous: Set<number>) => Set<number>) {
setRemovedHashes((previous) => mutator(previous));
onDone();
}
return (
<div className="authorization-block">
<div className="table-wrap">
<table className="data-table authorization-table">
<thead>
<tr>
<th></th>
<th></th>
<th>IP</th>
<th></th>
<th className="device-actions-head"></th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => (
<tr key={row.Hash}>
<td className="device-text">{row.DeviceModel} {row.SystemVersion}</td>
<td className="device-text">{row.Platform} {row.AppVersion}</td>
<td>{row.IP}</td>
<td>{formatDate(row.ActiveAt)}</td>
<td className="device-actions-cell">
<div className="device-actions">
<ActionButton
label="撤销当前"
icon={<LogOut size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, hash: row.Hash })}
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/>
<ActionButton
label="保留当前"
icon={<ShieldCheck size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, keep_hash: row.Hash })}
onDone={() => afterRevoke(() => new Set(rows.filter((item) => item.Hash !== row.Hash).map((item) => item.Hash)))}
/>
</div>
</td>
</tr>
))}
{visibleRows.length === 0 && <EmptyRow colSpan={5} />}
</tbody>
</table>
</div>
<div className="danger-zone">
<ActionButton
label="撤销全部设备"
icon={<Cable size={15} />}
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })}
onDone={() => afterRevoke(() => new Set(rows.map((item) => item.Hash)))}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types";
import { Badge } from "./ui";
export function UserPicker({
label,
value,
onChange
}: {
label: string;
value: AccountRow | null;
onChange: (row: AccountRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.accounts(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{displayName(value)}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || displayPhone(value.Phone) || "-"}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 user_id / phone / username"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}
export function ChannelPicker({
label,
value,
onChange
}: {
label: string;
value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.channels(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || channelKind(value)}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 channel_id / username / title"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row)}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge>{channelKind(row)}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,155 @@
import {
ChevronDown,
Database,
LayoutDashboard,
LogOut,
MessageSquareText,
Server,
Shield,
ShieldCheck,
Users
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { AppLink } from "./AppLink";
export function BootScreen() {
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<div className="loader-bar" />
</div>
);
}
export function Shell({
actor,
route,
navigate,
onLogout,
children
}: {
actor: string;
route: RouteState;
navigate: Navigate;
onLogout: () => void;
children: ReactNode;
}) {
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() {
await api.logout().catch(() => undefined);
onLogout();
}
return (
<div className="shell">
<aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</AppLink>
<div className="sidebar-label"></div>
<nav className="nav-list" aria-label="主导航">
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}></NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}></NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>/</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
type="button"
aria-expanded={messagesOpen}
onClick={() => setMessagesOpen((open) => !open)}
>
<MessageSquareText size={16} />
<span></span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
<div className="nav-children">
<NavLink
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
</NavLink>
</div>
)}
</div>
</nav>
<div className="sidebar-status">
<div className="sidebar-label"></div>
<div className="runtime-row"><Server size={14} /><span></span><strong></strong></div>
<div className="runtime-row"><Database size={14} /><span>PG </span><strong></strong></div>
<div className="runtime-row"><Shield size={14} /><span></span><strong></strong></div>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div>
<div className="eyebrow">{routeSubtitle(route.path)}</div>
<h1>{routeTitle(route.path)}</h1>
</div>
<div className="topbar-actions">
<span className="actor-pill">{actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title="退出">
<LogOut size={16} /> 退
</button>
</div>
</header>
<main className="content">{children}</main>
</div>
</div>
);
}
function NavLink({
href,
route,
navigate,
icon,
children,
activeWhen
}: {
href: string;
route: RouteState;
navigate: Navigate;
icon?: ReactNode;
children: ReactNode;
activeWhen?: (path: string) => boolean;
}) {
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
return (
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
{icon ?? <span aria-hidden="true" className="nav-dot" />}
<span>{children}</span>
</AppLink>
);
}

View file

@ -0,0 +1,128 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn";
export function PageFrame({
title,
eyebrow,
children,
actions
}: {
title: string;
eyebrow?: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="page-frame">
<div className="page-title-row">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h2>{title}</h2>
</div>
{actions && <div className="page-actions">{actions}</div>}
</div>
{children}
</div>
);
}
export function QueryPanel({ children }: { children: ReactNode }) {
return <div className="query-panel">{children}</div>;
}
export function SplitLayout({ main, side }: { main: ReactNode; side: ReactNode }) {
return (
<div className="split-layout">
<div className="split-main">{main}</div>
<aside className="split-side">{side}</aside>
</div>
);
}
export function SectionHead({ title, text, action }: { title: string; text?: string; action?: ReactNode }) {
return (
<div className="section-head">
<div>
<h2>{title}</h2>
{text && <p>{text}</p>}
</div>
{action && <div className="section-action">{action}</div>}
</div>
);
}
export function Alert({ children }: { children: ReactNode }) {
return <div className="alert"><CircleAlert size={16} /> <span>{children}</span></div>;
}
export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: Tone }) {
return <span className={`badge ${tone}`}>{children}</span>;
}
export function StatusItem({ label, value, tone }: { label: string; value: string; tone: "neutral" | "good" | "warn" }) {
return (
<div className={`status-item ${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
return (
<div className={`metric ${tone}`}>
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function Summary({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="summary-item">
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
return (
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th> ID</th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td>{row.ID}</td>
<td className="mono">{row.CommandID}</td>
<td>{row.Action}</td>
<td>{row.Actor}</td>
<td>{row.Status}</td>
<td>{row.DryRun ? "是" : "否"}</td>
<td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
);
}
export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell"></td></tr>;
}
export function LoadingSurface({ label }: { label: string }) {
return <section className="surface"><div className="loading-line">{label}</div></section>;
}
export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>;
}

View file

@ -0,0 +1,56 @@
import type { AccountRow, ChannelRow } from "../types";
export function displayPhone(value: string): string {
const phone = value.trim();
if (!phone || phone.startsWith("+")) return phone;
return /^\d+$/.test(phone) ? `+${phone}` : phone;
}
export function displayUsername(value: string): string {
const username = value.trim();
if (!username) return "";
return username.startsWith("@") ? username : `@${username}`;
}
export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): string {
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
}
export function channelKind(ch: ChannelRow): string {
if (ch.Broadcast && !ch.Megagroup) return "频道";
if (ch.Megagroup && ch.Forum) return "超级群/论坛";
if (ch.Megagroup) return "超级群";
return "频道/群";
}
export function formatDate(value: string): string {
if (!value || value.startsWith("0001-")) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function formatUnix(value: number): string {
if (!value || value <= 0) return "";
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function toInt(value: string): number {
if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function parseIDs(value: string): number[] {
const ids = value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => Number.parseInt(item, 10));
if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) {
throw new Error("msg ids invalid");
}
return ids;
}

View file

@ -0,0 +1,25 @@
import type { AccountRow, ChannelRow } from "../types";
export function accountMetrics(rows: AccountRow[]) {
return rows.reduce(
(acc, row) => {
acc.devices += row.DeviceCount;
if (row.PremiumUntil > 0) acc.premium += 1;
if (row.Frozen) acc.frozen += 1;
return acc;
},
{ devices: 0, premium: 0, frozen: 0 }
);
}
export function channelMetrics(rows: ChannelRow[]) {
return rows.reduce(
(acc, row) => {
if (row.Megagroup) acc.megagroups += 1;
if (row.Broadcast) acc.broadcasts += 1;
if (row.Verified) acc.verified += 1;
return acc;
},
{ megagroups: 0, broadcasts: 0, verified: 0 }
);
}

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,134 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
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 { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.account(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 ? "加载账号详情" : "等待数据"} />;
}
const account = detail.Account;
return (
<PageFrame
title={`账号 #${account.ID}`}
eyebrow="账号档案"
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div>
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good"></Badge> : <Badge></Badge>}
{detail.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{account.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title="授权设备" text={`${detail.Authorizations.length} 个授权`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load}
/>
<label className="duration-field">
<span></span>
<input
aria-label="设置会员时长,单位月"
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label="设置会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label="取消会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
<ActionButton
label={detail.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
</div>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,124 @@
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 { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = 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.beforeID));
params.set("before_active_us", String(cursor.beforeActiveUS));
}
try {
const result = await api.accounts(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeActiveUS: result.next_before_active_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = accountMetrics(data?.rows ?? []);
return (
<PageFrame
title="账号"
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</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="用户 ID / 手机号 / 用户名" />
</label>
<label className="field-inline">
<span></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} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good"> {formatUnix(row.PremiumUntil)}</Badge> : <Badge></Badge>}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,92 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载频道详情" />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow="频道档案"
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · {ch.CreatorUserID}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{ch.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="频道 ID" value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={ch.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,122 @@
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 { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = 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.beforeID));
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = channelMetrics(data?.rows ?? []);
return (
<PageFrame
title="超级群与频道"
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
</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="频道 ID / 用户名 / 标题" />
</label>
<label className="field-inline">
<span></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} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,59 @@
import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareText, ShieldCheck, Users } from "lucide-react";
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow"></div>
<h2></h2>
</div>
<div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span></span></div>
<div className="strip-item"><KeyRound size={16} /><span> token</span></div>
<div className="strip-item"><Clock3 size={16} /><span>使</span></div>
<div className="strip-item"><FileJson size={16} /><span></span></div>
</section>
</div>
);
}
function Launcher({
icon,
title,
text,
href,
navigate
}: {
icon: ReactNode;
title: string;
text: string;
href: string;
navigate: Navigate;
}) {
return (
<AppLink className="launcher" href={href} navigate={navigate}>
<span className="launcher-icon">{icon}</span>
<span className="launcher-copy">
<strong>{title}</strong>
<span>{text}</span>
</span>
<ChevronRight size={16} />
</AppLink>
);
}

View file

@ -0,0 +1,100 @@
import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.groupMessage(channelID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [channelID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载群聊消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`群聊消息 #${msg.ID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> </button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">/ {msg.ChannelID}</div>
<div className="entity-subtitle"> {msg.SenderUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
{msg.Pinned && <Badge tone="warn"></Badge>}
{msg.Post && <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息 ID" value={String(msg.ID)} mono />
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label="浏览" value={String(msg.ViewsCount)} />
</div>
<section className="section-block">
<SectionHead title="消息行" text="channel_messages 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title="频道行" text="channels 只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
<section className="section-block">
<SectionHead title="频道更新事件" text="durable channel_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th> ID</th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
<td>{row.PTS}</td>
<td>{row.PTSCount}</td>
<td>{row.Type}</td>
<td>{row.MessageID}</td>
<td>{row.SenderUserID}</td>
<td>{formatUnix(row.Date)}</td>
</tr>
))}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="事件 JSON" />
<div className="raw-grid">
{detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel"></div>}
</div>
</section>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,119 @@
import { ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [data, setData] = useState<GroupMessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!channel) {
setError("请先搜索并选择超级群或频道");
return;
}
const params = new URLSearchParams({
channel_id: String(channel.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.ID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.ID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.groupMessages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeChannel(row: ChannelRow | null) {
setChannel(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
const rows = data?.rows ?? [];
return (
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(rows.length)} />
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} />
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th>From Peer</th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.ChannelID}-${row.ID}`}>
<td className="mono">{row.ID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.SenderUserID}</td>
<td className="mono">{row.FromPeerType}:{row.FromPeerID}</td>
<td>{row.PTS}</td>
<td>{row.ViewsCount}</td>
<td>
{row.Deleted ? <Badge tone="danger"></Badge> : row.Pinned ? <Badge tone="warn"></Badge> : <Badge></Badge>}
</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,61 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await api.login(secret);
onLogin(result.actor);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<span className="login-chip">访</span>
</div>
<div className="login-copy">
<h1></h1>
<p></p>
</div>
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span> token</span>
<input
autoFocus
type="password"
value={secret}
autoComplete="current-password"
onChange={(event) => setSecret(event.target.value)}
/>
</label>
<button className="btn primary full" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"}
</button>
</form>
</section>
</main>
);
}

View file

@ -0,0 +1,116 @@
import { ArrowLeft, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.message(ownerUserID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [ownerUserID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`消息 #${msg.BoxID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title"> {msg.OwnerUserID} · {msg.PeerID}</div>
<div className="entity-subtitle"> {msg.FromUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th></th><th>PTS</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label="删除此消息"
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,157 @@
import { ChevronRight, History, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [ids, setIDs] = useState("");
const [revoke, setRevoke] = useState(true);
const [justClear, setJustClear] = useState(false);
const [maxID, setMaxID] = useState("");
const [maxBatches, setMaxBatches] = useState("1");
const [data, setData] = useState<MessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户");
return;
}
const params = new URLSearchParams({
owner_user_id: String(owner.ID),
peer_id: String(peer.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.BoxID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.BoxID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.messages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeOwner(row: AccountRow | null) {
setOwner(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
function changePeer(row: AccountRow | null) {
setPeer(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> </div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID逗号分隔" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> </div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
max_batches: toInt(maxBatches),
just_clear: justClear,
revoke
})} />
</div>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={`${row.OwnerUserID}-${row.BoxID}`}>
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? "发出" : "收到"}</td>
<td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,52 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
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];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups/detail") {
return (
<GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups") {
return <GroupMessagesPage navigate={navigate} />;
}
if (route.path === "/messages" || route.path === "/messages/private") {
return <MessagesPage navigate={navigate} />;
}
return <Dashboard navigate={navigate} />;
}

View file

@ -0,0 +1,29 @@
export type Navigate = (href: string) => void;
export type RouteState = {
href: string;
path: string;
search: URLSearchParams;
};
export function currentRoute(): RouteState {
return {
href: `${window.location.pathname}${window.location.search}`,
path: window.location.pathname,
search: new URLSearchParams(window.location.search)
};
}
export function routeTitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "账号管理";
if (pathname.startsWith("/channels")) return "超级群与频道";
if (pathname.startsWith("/messages")) return "消息审计";
return "运维控制台";
}
export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "控制台 / 账号";
if (pathname.startsWith("/channels")) return "控制台 / 频道";
if (pathname.startsWith("/messages")) return "控制台 / 消息";
return "控制台 / 总览";
}

View file

@ -0,0 +1,5 @@
@import "./styles/01-foundation.css";
@import "./styles/02-pages-and-forms.css";
@import "./styles/03-entities-and-actions.css";
@import "./styles/04-modal-and-login.css";
@import "./styles/05-responsive.css";

View file

@ -0,0 +1,285 @@
:root {
color-scheme: light;
--bg: #f3f5f7;
--panel: #ffffff;
--panel-subtle: #f8fafb;
--panel-strong: #eef2f5;
--line: #d9e1e8;
--line-strong: #c2ccd6;
--text: #101828;
--muted: #667085;
--muted-2: #98a2b3;
--brand: #176d61;
--brand-2: #245b9d;
--good: #167447;
--warn: #a15c07;
--danger: #b42318;
--sidebar: #11161d;
--sidebar-soft: #1b222b;
--sidebar-line: #2c3541;
--focus: rgba(23, 109, 97, 0.16);
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
background: var(--bg);
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.shell {
display: grid;
min-height: 100vh;
grid-template-columns: 232px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
flex-direction: column;
gap: 16px;
padding: 18px 12px;
color: #eef2f6;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 4px;
}
.brand.compact {
justify-content: center;
}
.brand-elevated .brand-mark {
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
}
.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: 8px;
font-weight: 800;
}
.brand strong {
display: block;
font-size: 14px;
line-height: 1.1;
}
.brand small {
display: block;
margin-top: 3px;
color: #aeb8c4;
font-size: 11px;
}
.sidebar-label {
padding: 0 8px;
color: #8492a6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-section {
display: grid;
gap: 4px;
}
.nav-section-toggle {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
width: 100%;
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #8fa0b4;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
font-weight: 800;
text-align: left;
}
.nav-section-toggle:hover,
.nav-section.active .nav-section-toggle {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-section-chevron {
justify-self: end;
color: #8fa0b4;
transition: transform 140ms ease;
}
.nav-section.open .nav-section-chevron {
transform: rotate(180deg);
}
.nav-children {
display: grid;
gap: 4px;
padding: 2px 0 2px 18px;
}
.nav-item {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #c6d0dc;
border: 1px solid transparent;
border-radius: 7px;
}
.nav-dot {
width: 6px;
height: 6px;
justify-self: center;
background: #687789;
border-radius: 999px;
}
.nav-item:hover,
.nav-item.active {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-item.active .nav-dot {
background: var(--brand);
}
.sidebar-status {
display: grid;
gap: 7px;
margin-top: auto;
}
.runtime-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 32px;
align-items: center;
gap: 7px;
padding: 0 8px;
color: #cbd5df;
background: #171d25;
border: 1px solid #27313c;
border-radius: 7px;
}
.runtime-row strong {
color: #ffffff;
font-size: 11px;
}
.workspace {
min-width: 0;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.94);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
.topbar h1 {
margin: 2px 0 0;
font-size: 20px;
line-height: 1.2;
}
.topbar-actions,
.page-actions,
.section-action,
.entity-badges,
.row-actions,
.modal-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.actor-pill {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 10px;
color: #344054;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.content {
display: grid;
gap: 16px;
padding: 18px 24px 30px;
}
.eyebrow {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}

View file

@ -0,0 +1,559 @@
.dashboard-layout,
.stacked-sections {
display: grid;
gap: 14px;
}
.overview-band,
.page-frame {
min-width: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.overview-band {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(420px, 0.9fr);
gap: 16px;
align-items: center;
padding: 16px;
}
.overview-band h2,
.page-title-row h2,
.section-head h2,
.modal h2 {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.overview-metrics,
.metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 8px;
}
.overview-metrics {
grid-template-columns: repeat(3, minmax(120px, 1fr));
}
.status-item,
.metric,
.summary-item {
min-width: 0;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
}
.status-item span,
.metric span,
.summary-item span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
}
.status-item strong,
.metric strong,
.summary-item strong {
display: block;
overflow-wrap: anywhere;
color: var(--text);
font-weight: 800;
}
.status-item.good,
.metric.good {
border-color: #afd8bf;
}
.status-item.warn,
.metric.warn {
border-color: #e7c77e;
}
.metric.danger {
border-color: #efb4ad;
}
.command-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 12px;
}
.launcher {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 18px;
min-height: 94px;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.launcher:hover {
border-color: var(--brand);
}
.launcher-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 8px;
}
.launcher-copy {
display: grid;
gap: 4px;
}
.launcher-copy strong {
font-size: 15px;
}
.launcher-copy span {
color: var(--muted);
}
.work-strip {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 8px;
}
.strip-item {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: #344054;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.page-frame {
display: grid;
gap: 14px;
padding: 14px;
}
.page-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.query-panel {
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.message-query input {
width: 150px;
}
.message-selector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.message-selector-grid.single {
grid-template-columns: minmax(320px, 620px);
}
.entity-picker {
display: grid;
min-width: 0;
gap: 8px;
padding: 10px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
}
.picker-head {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #344054;
font-weight: 800;
}
.selected-entity {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 40px;
align-items: center;
gap: 8px;
padding: 7px 9px;
color: #0f3f38;
background: #eef8f5;
border: 1px solid #b9dcd3;
border-radius: 7px;
}
.selected-entity strong,
.selected-entity span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-entity div {
display: grid;
min-width: 0;
gap: 2px;
}
.selected-entity div span {
color: #52606d;
font-size: 11px;
}
.picker-search {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
height: 34px;
align-items: center;
gap: 7px;
padding: 0 6px 0 9px;
background: var(--panel-subtle);
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.picker-search input {
width: 100%;
height: 30px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.picker-results {
display: grid;
max-height: 236px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 7px;
}
.picker-row {
display: grid;
grid-template-columns: 96px minmax(120px, 1fr) minmax(120px, 1fr) auto;
min-height: 36px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: #ffffff;
border: 0;
border-bottom: 1px solid var(--line);
cursor: pointer;
text-align: left;
}
.picker-row:last-child {
border-bottom: 0;
}
.picker-row:hover,
.picker-row.selected {
background: #f3f8f6;
}
.picker-row strong,
.picker-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-empty,
.picker-error {
padding: 9px;
color: var(--muted);
text-align: center;
}
.picker-error {
color: var(--danger);
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 7px;
}
input,
textarea {
color: var(--text);
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
outline: none;
}
input {
width: 190px;
height: 34px;
padding: 0 10px;
}
textarea {
width: 100%;
padding: 9px 10px;
resize: vertical;
}
input:focus,
textarea:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus);
}
.small-input {
width: 88px;
}
.field-inline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.field-inline span {
font-size: 11px;
font-weight: 700;
}
.searchbox {
display: inline-flex;
align-items: center;
gap: 8px;
width: min(380px, 100%);
height: 34px;
padding: 0 10px;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.searchbox input {
width: 100%;
height: 30px;
padding: 0;
border: 0;
box-shadow: none;
}
.btn {
display: inline-flex;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
color: #1d2939;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: #f7f9fb;
}
.btn:disabled {
color: var(--muted-2);
cursor: not-allowed;
}
.btn.primary {
color: #ffffff;
background: var(--brand);
border-color: var(--brand);
}
.btn.primary:hover:not(:disabled) {
background: #12594f;
}
.btn.ghost {
background: var(--panel-subtle);
}
.btn.danger {
color: var(--danger);
background: #fff7f5;
border-color: #efb4ad;
}
.btn.danger:hover:not(:disabled) {
background: #ffeceb;
}
.btn.warn {
color: var(--warn);
background: #fff8ec;
border-color: #e7c77e;
}
.btn.warn:hover:not(:disabled) {
background: #fff1d6;
}
.btn:disabled,
.btn.primary:disabled,
.btn.warn:disabled,
.btn.danger:disabled {
color: var(--muted-2);
background: #f3f5f7;
border-color: var(--line);
cursor: not-allowed;
}
.btn.full {
width: 100%;
}
.icon-text {
gap: 7px;
}
.compact-btn {
min-height: 28px;
padding: 0 8px;
font-size: 12px;
}
.row-link,
.link-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: var(--brand-2);
background: transparent;
border: 0;
cursor: pointer;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.data-table th,
.data-table td {
height: 38px;
padding: 7px 9px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.data-table th {
position: sticky;
top: 0;
z-index: 0;
color: #475467;
background: var(--panel-strong);
font-weight: 800;
}
.data-table tbody tr:hover {
background: #fbfcfd;
}
.data-table tr:last-child td {
border-bottom: 0;
}
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.truncate {
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
min-height: 22px;
align-items: center;
padding: 1px 8px;
color: #4f5b68;
background: #f3f6f8;
border: 1px solid #d7e0e8;
border-radius: 999px;
white-space: nowrap;
}
.badge.good {
color: var(--good);
background: #eef8f2;
border-color: #b9dcc7;
}
.badge.danger {
color: var(--danger);
background: #fff2f0;
border-color: #efb4ad;
}
.badge.warn {
color: var(--warn);
background: #fff8e7;
border-color: #e7c77e;
}
.empty-cell {
color: var(--muted);
text-align: center;
}

View file

@ -0,0 +1,254 @@
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 14px;
align-items: start;
}
.split-main,
.split-side {
min-width: 0;
}
.entity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.entity-title {
font-size: 20px;
font-weight: 800;
line-height: 1.25;
}
.entity-subtitle {
margin-top: 4px;
color: var(--muted);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 8px;
}
.about-text {
margin: 0;
padding: 10px;
color: #344054;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.section-block,
.action-dock,
.surface {
min-width: 0;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-head p {
margin: 5px 0 0;
color: var(--muted);
}
.action-dock {
position: sticky;
top: 82px;
display: grid;
gap: 10px;
}
.dock-title {
padding-bottom: 4px;
color: #344054;
font-weight: 800;
border-bottom: 1px solid var(--line);
}
.action-dock > .btn,
.action-dock .action-stack .btn {
width: 100%;
justify-content: center;
}
.duration-field {
display: grid;
gap: 4px;
}
.duration-field span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
}
.duration-field input {
width: 100%;
}
.action-stack {
display: grid;
gap: 10px;
}
.action-stack .btn,
.action-dock > .btn {
min-height: 42px;
}
.danger-zone {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.authorization-block {
display: grid;
gap: 10px;
}
.authorization-table {
min-width: 720px;
table-layout: fixed;
}
.authorization-table th,
.authorization-table td {
height: 46px;
}
.device-text {
overflow: hidden;
text-overflow: ellipsis;
}
.device-text {
max-width: 260px;
}
.device-actions-head {
width: 190px;
}
.device-actions-cell {
width: 190px;
min-width: 190px;
}
.device-actions {
display: grid;
grid-template-columns: repeat(2, minmax(82px, 1fr));
gap: 6px;
min-width: 178px;
white-space: normal;
}
.device-actions .btn {
width: 100%;
justify-content: center;
}
.operation-row {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
}
.operation-box {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.operation-title {
display: flex;
width: 100%;
align-items: center;
gap: 6px;
font-weight: 800;
}
.checkline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.checkline input {
width: auto;
height: auto;
}
.alert {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
color: #8a251d;
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 8px;
}
.json-block {
max-height: 520px;
overflow: auto;
margin: 0;
padding: 12px;
color: #d8e6f0;
background: #141a22;
border: 1px solid #2a3542;
border-radius: 8px;
font-size: 12px;
}
.raw-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.loading-line {
min-height: 80px;
display: grid;
place-items: center;
color: var(--muted);
}
.empty-panel {
display: grid;
min-height: 92px;
place-items: center;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}

View file

@ -0,0 +1,261 @@
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(17, 24, 39, 0.52);
}
.modal {
width: min(760px, 100%);
max-height: min(820px, calc(100vh - 48px));
overflow: hidden;
padding: 0;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.command-modal {
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line);
}
.icon-btn {
display: grid;
width: 30px;
height: 30px;
place-items: center;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
cursor: pointer;
}
.command-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.command-body {
display: grid;
gap: 12px;
overflow: auto;
padding: 14px 18px;
}
.command-step {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.command-step span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-weight: 800;
}
.command-step.active {
color: var(--brand);
border-color: #a9d8ce;
}
.command-step.done {
color: var(--good);
border-color: #b9dcc7;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field span,
.form-stack span {
color: #4b5563;
font-weight: 800;
}
.command-preview {
display: grid;
gap: 8px;
}
.command-preview .json-block {
max-height: 150px;
}
.preview-head,
.result-title {
display: flex;
align-items: center;
gap: 7px;
color: #344054;
font-weight: 800;
}
.result-box {
display: grid;
gap: 8px;
padding: 10px;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.result-line {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.result-line span {
color: var(--muted);
}
.result-line strong {
overflow-wrap: anywhere;
}
.result-message {
color: #344054;
}
.modal-actions {
justify-content: flex-end;
padding: 12px 18px;
background: #ffffff;
border-top: 1px solid var(--line);
}
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
background: var(--bg);
}
.login-panel {
display: grid;
width: min(420px, 100%);
gap: 18px;
padding: 22px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.login-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-chip {
display: inline-flex;
min-height: 24px;
align-items: center;
padding: 0 8px;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 999px;
font-size: 12px;
}
.login-copy h1 {
margin: 0;
font-size: 22px;
}
.login-copy p {
margin: 8px 0 0;
color: var(--muted);
}
.form-stack {
display: grid;
gap: 12px;
}
.form-stack label {
display: grid;
gap: 6px;
}
.form-stack input {
width: 100%;
}
.boot-screen {
display: grid;
min-height: 100vh;
place-items: center;
align-content: center;
gap: 18px;
}
.loader-bar {
width: 180px;
height: 4px;
overflow: hidden;
background: #d7dde4;
border-radius: 999px;
}
.loader-bar::before {
display: block;
width: 42%;
height: 100%;
content: "";
background: var(--brand);
animation: load 1s infinite ease-in-out;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes load {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(260%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,78 @@
@media (max-width: 1120px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.nav-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.sidebar-status {
display: none;
}
.overview-band,
.split-layout,
.operation-row,
.raw-grid,
.message-selector-grid,
.message-selector-grid.single {
grid-template-columns: 1fr;
}
.action-dock {
position: static;
}
}
@media (max-width: 760px) {
.content,
.topbar {
padding-left: 14px;
padding-right: 14px;
}
.command-grid,
.work-strip,
.overview-metrics,
.metric-row,
.summary-grid,
.command-steps {
grid-template-columns: 1fr;
}
.sidebar {
gap: 12px;
padding: 14px;
}
.nav-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.topbar,
.page-title-row,
.entity-head {
align-items: flex-start;
flex-direction: column;
}
input,
.searchbox {
width: 100%;
}
.toolbar {
align-items: stretch;
}
.picker-row,
.selected-entity {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,222 @@
export type AccountRow = {
ID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
CreatedAt: string;
UpdatedAt: string;
Frozen: boolean;
Reason: string;
Verified: boolean;
PremiumUntil: number;
LastActiveAt: string;
DeviceCount: number;
};
export type RestrictionRow = {
Frozen: boolean;
Reason: string;
Actor: string;
CommandID: string;
UpdatedAt: string;
};
export type AuthorizationRow = {
AuthKeyID: number;
Hash: number;
Layer: number;
DeviceModel: string;
Platform: string;
SystemVersion: string;
APIID: number;
AppVersion: string;
IP: string;
PasswordPending: boolean;
CreatedAt: string;
ActiveAt: string;
};
export type AuditLogRow = {
ID: number;
CommandID: string;
Actor: string;
Action: string;
DryRun: boolean;
Reason: string;
Status: string;
Error: string;
Result: string;
CreatedAt: string;
};
export type AccountDetail = {
Account: AccountRow;
About: string;
LastSeenAt: number;
Verified: boolean;
Support: boolean;
Bot: boolean;
Restriction: RestrictionRow;
HasRestriction: boolean;
Authorizations: AuthorizationRow[];
AuditLogs: AuditLogRow[];
};
export type ChannelRow = {
ID: number;
AccessHash: number;
CreatorUserID: number;
Title: string;
About: string;
Username: string;
Broadcast: boolean;
Megagroup: boolean;
Forum: boolean;
Monoforum: boolean;
Verified: boolean;
Deleted: boolean;
ParticipantsCount: number;
AdminsCount: number;
KickedCount: number;
BannedCount: number;
TopMessageID: number;
PinnedMessageID: number;
PTS: number;
Date: number;
CreatedAt: string;
UpdatedAt: string;
};
export type ChannelDetail = {
Channel: ChannelRow;
ChannelJSON: string;
AuditLogs: AuditLogRow[];
};
export type MessageRow = {
OwnerUserID: number;
BoxID: number;
PrivateMessageID: number;
MessageSenderID: number;
PeerID: number;
FromUserID: number;
Date: number;
Outgoing: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
};
export type GroupMessageRow = {
ChannelID: number;
ID: number;
SenderUserID: number;
FromPeerType: string;
FromPeerID: number;
Date: number;
Post: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
ViewsCount: number;
EditDate: number;
Pinned: boolean;
};
export type UpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
Date: number;
JSON: string;
};
export type ChannelUpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
MessageID: number;
Date: number;
SenderUserID: number;
JSON: string;
};
export type OutboxRow = {
ID: number;
TargetUserID: number;
PTS: number;
EventType: string;
Status: string;
Attempts: number;
CreatedAt: string;
UpdatedAt: string;
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;
DialogJSON: string;
PrivateJSON: string;
UpdateEvents: UpdateEventRow[];
Outbox: OutboxRow[];
};
export type GroupMessageDetail = {
Message: GroupMessageRow;
MessageJSON: string;
ChannelJSON: string;
UpdateEvents: ChannelUpdateEventRow[];
};
export type CommandResult = {
command_id: string;
action: string;
status: string;
already_executed: boolean;
dry_run: boolean;
target_user_id?: number;
target_peer?: unknown;
message: string;
details?: Record<string, unknown>;
error?: string;
};
export type AccountListResponse = {
query: string;
limit: number;
rows: AccountRow[];
has_more: boolean;
next_before_id: number;
next_before_active_us: number;
listing: boolean;
};
export type ChannelListResponse = {
query: string;
limit: number;
rows: ChannelRow[];
has_more: boolean;
next_before_id: number;
next_before_updated_us: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;
before_date: number;
before_id: number;
limit: number;
rows: MessageRow[];
};
export type GroupMessageListResponse = {
channel_id: number;
before_date: number;
before_id: number;
limit: number;
rows: GroupMessageRow[];
};

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View file

@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true
},
server: {
port: 2410,
proxy: {
"/api": "http://127.0.0.1:2400"
}
}
});

54
cmd/telesrv/buildinfo.go Normal file
View file

@ -0,0 +1,54 @@
package main
import "runtime/debug"
var (
gitCommit = ""
gitBranch = ""
gitTreeState = ""
buildTime = ""
)
type buildMetadata struct {
Commit string
Branch string
TreeState string
BuildTime string
GoVersion string
}
func currentBuildMetadata() buildMetadata {
meta := buildMetadata{
Commit: valueOrUnknown(gitCommit),
Branch: valueOrUnknown(gitBranch),
TreeState: valueOrUnknown(gitTreeState),
BuildTime: valueOrUnknown(buildTime),
}
if info, ok := debug.ReadBuildInfo(); ok {
meta.GoVersion = info.GoVersion
settings := map[string]string{}
for _, setting := range info.Settings {
settings[setting.Key] = setting.Value
}
if meta.Commit == "unknown" {
meta.Commit = valueOrUnknown(settings["vcs.revision"])
}
if meta.TreeState == "unknown" {
switch settings["vcs.modified"] {
case "true":
meta.TreeState = "dirty"
case "false":
meta.TreeState = "clean"
}
}
}
meta.GoVersion = valueOrUnknown(meta.GoVersion)
return meta
}
func valueOrUnknown(v string) string {
if v == "" {
return "unknown"
}
return v
}

View file

@ -13,6 +13,7 @@ import (
"go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap"
tdcrypto "github.com/gotd/td/crypto"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
@ -104,7 +105,7 @@ func TestExecutablePrivateMessageRoundTrip(t *testing.T) {
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
Resolver: noSecretObfuscatedResolver{addr: addr, dc: 2},
DCList: dcs.List{Options: []tg.DCOption{{ID: 2, IPAddress: host, Port: port, Static: true}}},
Logger: zaptest.NewLogger(t).Named("exe-client"),
Logger: logzap.New(zaptest.NewLogger(t).Named("exe-client")),
SessionStorage: storage,
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
}

View file

@ -5,39 +5,65 @@ import (
"context"
"fmt"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/gotd/td/clock"
"github.com/gotd/td/exchange"
"github.com/gotd/td/tg"
adminapp "telesrv/internal/admin"
"telesrv/internal/adminapi"
"telesrv/internal/app/account"
"telesrv/internal/app/auth"
botsapp "telesrv/internal/app/bots"
channelapp "telesrv/internal/app/channels"
"telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs"
filesapp "telesrv/internal/app/files"
groupcallsapp "telesrv/internal/app/groupcalls"
"telesrv/internal/app/help"
"telesrv/internal/app/langpack"
"telesrv/internal/app/maintenance"
messageapp "telesrv/internal/app/messages"
passkeyapp "telesrv/internal/app/passkey"
phoneapp "telesrv/internal/app/phone"
pollsapp "telesrv/internal/app/polls"
privacyapp "telesrv/internal/app/privacy"
secretchatapp "telesrv/internal/app/secretchat"
"telesrv/internal/app/stargifts"
"telesrv/internal/app/stars"
storiesapp "telesrv/internal/app/stories"
themesapp "telesrv/internal/app/themes"
"telesrv/internal/app/updates"
"telesrv/internal/app/userprojection"
"telesrv/internal/app/users"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
"telesrv/internal/rpc"
"telesrv/internal/seed/catalog"
"telesrv/internal/sfu"
storepkg "telesrv/internal/store"
"telesrv/internal/store/memory"
"telesrv/internal/store/postgres"
"telesrv/internal/store/redisstore"
"telesrv/internal/turnsrv"
)
func main() {
logger, err := zap.NewDevelopment()
logger, err := newLogger()
if err != nil {
fmt.Fprintln(os.Stderr, "init logger:", err)
os.Exit(1)
@ -46,15 +72,116 @@ func main() {
if err := run(logger); err != nil {
logger.Error("telesrv 退出", zap.Error(err))
_ = logger.Sync() // os.Exit 跳过 defer缓冲写需显式 flush 错误日志
os.Exit(1)
}
}
// newLogger 构建运行日志器。两项关键改造(相对旧的 zap.NewDevelopment
// - 级别可配TELESRV_LOG_LEVEL默认 info旧版固定 Debug热路径 65 处 Debug
// mtprotoedge 每帧一条)在连接洪峰会刷爆日志。生产/压测用 info 即可,需要时设 debug。
// - 缓冲异步写BufferedWriteSyncer旧版每条日志一次 stderr 同步写 + 全局锁,高并发下
// 在日志锁上串行累积——实测连接时 12 个并发 RPC 的 client_info 阶段被拖成 ~1s 惊群
// mutex profile 91% 竞争在 zap 写)。缓冲后写入批量化,锁持有时间从「每条一次系统调用」
// 降到「攒一批刷一次」。FlushInterval 控制日志可见延迟上界。
func newLogger() (*zap.Logger, error) {
level := zapcore.InfoLevel
if v := strings.TrimSpace(os.Getenv("TELESRV_LOG_LEVEL")); v != "" {
if err := level.UnmarshalText([]byte(strings.ToLower(v))); err != nil {
return nil, fmt.Errorf("parse TELESRV_LOG_LEVEL %q: %w", v, err)
}
}
ws := &zapcore.BufferedWriteSyncer{
WS: zapcore.AddSync(os.Stderr),
FlushInterval: 500 * time.Millisecond,
}
core := zapcore.NewCore(zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), ws, level)
return zap.New(core,
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.AddSync(os.Stderr)),
), nil
}
func newBusinessAutomationOptions(cfg config.Config, online messageapp.BusinessAutomationOnlineChecker, logger *zap.Logger) []messageapp.BusinessAutomationOption {
opts := []messageapp.BusinessAutomationOption{
messageapp.WithBusinessAutomationOnlineChecker(online),
}
provider := strings.ToLower(strings.TrimSpace(cfg.BusinessAIProvider))
switch provider {
case "", "echo":
opts = append(opts, messageapp.WithBusinessAutomationReplyProvider(messageapp.NewEchoBusinessAutomationProvider()))
logger.Info("Business automation reply provider", zap.String("provider", "echo"))
case "template", "quick_reply", "quick-reply":
logger.Info("Business automation reply provider", zap.String("provider", "template"))
default:
logger.Warn("未知 Business automation AI provider回退 quick reply 模板", zap.String("provider", cfg.BusinessAIProvider))
}
return opts
}
// startDebugServer 在 addr 上挂起 net/http/pprof 调试端点addr 为空则关闭)。
// 用独立 mux不污染 http.DefaultServeMux仅注册 pprof 路由:
// - /debug/pprof/profile CPU 剖析(?seconds=30
// - /debug/pprof/heap 堆内存快照
// - /debug/pprof/goroutine goroutine 栈(排查泄漏/阻塞)
// - /debug/pprof/mutex 锁竞争(需 SetMutexProfileFraction
// - /debug/pprof/block 阻塞剖析(需 SetBlockProfileRate
// - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关)
//
// mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。
func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
if addr == "" {
return
}
runtime.SetMutexProfileFraction(5) // 采样 1/5 的锁竞争事件
runtime.SetBlockProfileRate(10000) // 每阻塞约 10µs 采一次样
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index) // 含 heap/goroutine/mutex/block/allocs 等命名 profile
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
logger.Info("pprof 调试端点已启用", zap.String("addr", addr),
zap.String("hint", "go tool pprof http://"+addr+"/debug/pprof/profile?seconds=30"))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Warn("pprof 端点退出", zap.Error(err))
}
}()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
}
// externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option
func externalMediaOption(cfg config.Config) filesapp.Option {
if !cfg.ExternalMediaEnable {
return nil
}
return filesapp.WithExternalMedia(cfg.ExternalMediaMaxBytes, cfg.ExternalMediaRatePerMin)
}
// webPagePreviewOption 按配置启用链接预览抓取;禁用时返回 nilNewService 跳过 nil option
func webPagePreviewOption(cfg config.Config) filesapp.Option {
if !cfg.WebPagePreviewEnable {
return nil
}
return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin)
}
func run(logger *zap.Logger) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
buildMeta := currentBuildMetadata()
rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(cfg.RSAKeyPath)
if err != nil {
@ -71,12 +198,17 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("parse listen port %q: %w", portStr, err)
}
// tg.Layer 来自 gotd/td v0.144.0(应为 225与目标 TDesktop 基线对齐。
// tg.Layer 来自 gotd/td v0.158.0Layer 227与目标 TDesktop 基线对齐。
logger.Info("telesrv 启动",
zap.String("listen", cfg.ListenAddr),
zap.Int("dc", cfg.DC),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("tl_layer", tg.Layer),
zap.String("git_commit", buildMeta.Commit),
zap.String("git_branch", buildMeta.Branch),
zap.String("git_tree_state", buildMeta.TreeState),
zap.String("build_time", buildMeta.BuildTime),
zap.String("go_version", buildMeta.GoVersion),
zap.String("rsa_key", cfg.RSAKeyPath),
zap.Int64("rsa_fingerprint", fingerprint),
)
@ -84,11 +216,21 @@ func run(logger *zap.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// pprof 调试端点telesrv 是宿主进程(不在 docker 内docker stats 看不到它CPU/内存/
// goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。
startDebugServer(ctx, cfg.DebugAddr, logger)
// 持久化依赖:先迁移 schema再建立连接。auth_key 落 PostgreSQL、session 落 Redis。
// 依赖由 deploy/docker-compose.yml 启动;连不上则启动失败(开发期须先 docker compose up
if err := postgres.Migrate(cfg.PostgresDSN); err != nil {
migrationStatus, err := postgres.MigrateAndStatus(cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("postgres migrate: %w", err)
}
logger.Info("PostgreSQL schema 已迁移",
zap.Uint("schema_version", migrationStatus.Version),
zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty),
)
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
postgres.WithMaxConns(cfg.PostgresMaxConns),
postgres.WithMinConns(cfg.PostgresMinConns),
@ -113,25 +255,68 @@ func run(logger *zap.Logger) error {
authKeyStore := postgres.NewAuthKeyStore(pool)
userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool)
adminStore := postgres.NewAdminStore(pool)
updateStateStore := postgres.NewUpdateStateStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelPtsAllocator := redisstore.NewChannelPtsAllocator(rdb, postgres.NewChannelPtsCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
contactStore := postgres.NewContactStore(pool)
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
dialogStore := postgres.NewDialogStore(pool)
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator))
messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
channelStore := postgres.NewChannelStore(pool,
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
postgres.WithChannelRowCache(channelRowCache),
postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelBoostCache(channelBoostCache))
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
privacyStore := postgres.NewPrivacyStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool)
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
if err != nil {
return fmt.Errorf("init blob backend: %w", err)
}
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC)
logger.Info("blob backend 就绪",
zap.String("backend", "localfs"),
zap.String("dir", cfg.BlobDir),
)
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
filesapp.WithLogger(logger),
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
MaxBytes: cfg.UploadInFlightMaxBytes,
MaxParts: cfg.UploadInFlightMaxParts,
MaxFiles: cfg.UploadInFlightMaxFiles,
}),
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
externalMediaOption(cfg),
webPagePreviewOption(cfg),
)
if cfg.MapboxToken != "" {
logger.Info("地图缩略图代理已启用", zap.String("provider", "mapbox"), zap.String("cache_dir", cfg.MapTileCacheDir))
}
if cfg.ExternalMediaEnable {
logger.Info("外链媒体抓取已启用", zap.Int64("max_bytes", cfg.ExternalMediaMaxBytes), zap.Int("rate_per_min", cfg.ExternalMediaRatePerMin))
}
if cfg.WebPagePreviewEnable {
logger.Info("链接预览抓取已启用", zap.Int64("max_bytes", cfg.WebPagePreviewMaxBytes), zap.Int("rate_per_min", cfg.WebPagePreviewRatePerMin))
}
if stats, err := filesService.SeedMedia(ctx, cfg.StickerSeedDir, cfg.StickerSeedMaxSets); err != nil {
return fmt.Errorf("seed media: %w", err)
} else if !stats.Skipped {
@ -139,6 +324,17 @@ func run(logger *zap.Logger) error {
zap.String("dir", cfg.StickerSeedDir),
zap.Int("reactions", stats.Reactions),
zap.Int("sticker_sets", stats.StickerSets),
zap.Int("effects", stats.Effects),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
}
if stats, err := filesService.SeedAppearance(ctx); err != nil {
return fmt.Errorf("seed appearance: %w", err)
} else if !stats.Skipped {
logger.Info("外观种子导入完成",
zap.String("source", "default-seed"),
zap.Int("wallpapers", stats.Wallpapers),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
@ -152,55 +348,283 @@ func run(logger *zap.Logger) error {
zap.Int("blobs", stats.Blobs),
)
}
// 默认 emoji status 系统集:从 animated_emoji 精选合成(幂等,已 seed 的存量
// 库重启后自动补上);缺失时 premium 用户的 status 选择器会是空的。
if count, created, err := filesService.EnsureDefaultEmojiStatusSet(ctx); err != nil {
logger.Warn("默认 emoji status 系统集合成失败", zap.Error(err))
} else if created {
logger.Info("默认 emoji status 系统集已合成", zap.Int("documents", count))
}
langPackStore := postgres.NewLangPackStore(pool)
passwordStore := postgres.NewPasswordStore(pool)
helpStore := postgres.NewHelpStore(pool)
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
sessionStore := redisstore.NewSessionStore(rdb, redisstore.DefaultSessionTTL)
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
codeStore := redisstore.NewCodeStore(rdb)
rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
go maintenance.NewRetentionWorker(dispatchOutboxStore, logger.Named("maintenance").Named("retention"),
adminService := adminapp.NewService(adminapp.Dependencies{
Commands: adminStore,
Restrictions: adminStore,
})
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
cfg.UpdateEventRetention,
cfg.RetentionInterval,
cfg.RetentionBatch,
).Run(ctx)
go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"),
rpc.WithOutboxWorkers(cfg.OutboxWorkers),
rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
cfg.UploadPartTTL,
cfg.UploadPartGCInterval,
cfg.UploadPartGCBatch,
).Run(ctx)
langPackService := langpack.NewService(langPackStore)
privacyService := privacyapp.NewService(privacyStore, contactStore)
contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService),
contacts.WithReadModelVersions(readModelVersionStore),
)
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
return fmt.Errorf("seed langpack: %w", err)
} else if seeded > 0 {
logger.Info("语言包种子导入完成", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded))
}
// 国家区号目录:把 catalog 固化的官方全量(~235 国)幂等 upsert 进 PG,覆盖迁移里仅
// seed 的 2 国(US/CN)默认值。否则 countries 表非空,ListCountries 返回那 2 行就会
// 绕过 catalog,登录页/号码格式只显示 2 国。upsert 失败仅告警不阻断启动(回退旧 2 行)。
if cs := catalog.Countries().Countries; len(cs) > 0 {
if err := helpStore.UpsertCountries(ctx, cs); err != nil {
logger.Warn("国家区号种子导入失败", zap.Error(err))
} else {
logger.Info("国家区号种子导入完成", zap.Int("countries", len(cs)))
}
}
botStore := postgres.NewBotStore(pool)
// userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithLogger(logger.Named("bots")),
botsapp.WithBlockChecker(contactStore),
botsapp.WithPublicChannelUsernameResolver(channelStore),
botsapp.WithUserCache(userCache))
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore)
// 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活
// 回报给信令侧保活水位sweeper 双过期判据的实现未启用则退化为纯信令M0
sfuService := sfu.Service(sfu.Disabled())
if cfg.SFUEnable {
sfuAdvertise := cfg.SFUAdvertiseIP
if sfuAdvertise == "" {
sfuAdvertise = cfg.AdvertiseIP
}
pionSFU, err := sfu.NewPion(sfu.PionConfig{
UDPPort: cfg.SFUUDPPort,
AdvertiseIP: sfuAdvertise,
Logger: logger.Named("sfu"),
Touch: func(callID, userID int64) {
if _, _, err := groupCallsService.Touch(context.Background(), callID, userID, int(time.Now().Unix())); err != nil {
logger.Debug("sfu liveness touch", zap.Int64("call_id", callID), zap.Int64("user_id", userID), zap.Error(err))
}
},
})
if err != nil {
return fmt.Errorf("init sfu: %w", err)
}
sfuService = pionSFU
}
// 私聊通话中继P3内嵌 TURN/STUNphoneCall.connections 经 phoneConnectionWebrtc
// 下发。未启用时退回 P1 的纯信令 LAN 直连。
turnService := turnsrv.Service(turnsrv.Disabled())
if cfg.TURNEnable {
turnAdvertise := cfg.TURNAdvertiseIP
if turnAdvertise == "" {
turnAdvertise = cfg.SFUAdvertiseIP
}
if turnAdvertise == "" {
turnAdvertise = cfg.AdvertiseIP
}
t, err := turnsrv.New(turnsrv.Config{
UDPPort: cfg.TURNUDPPort,
AdvertiseIP: turnAdvertise,
SharedSecret: cfg.TURNSecret,
RelayMinPort: cfg.TURNRelayMinPort,
RelayMaxPort: cfg.TURNRelayMaxPort,
CredentialTTL: cfg.CallTURNCredentialTTL,
Logger: logger.Named("turn"),
})
if err != nil {
return fmt.Errorf("init turn: %w", err)
}
defer t.Close()
turnService = t
}
// 服务端重启恢复SFU 状态全失,把全部活跃通话的参与者批量置 leftversion++
// 客户端经 checkGroupCall 发现自己 ssrc 消失后自动 rejoin。
if calls, err := groupCallsService.ResetAllParticipants(ctx, int(time.Now().Unix())); err != nil {
logger.Warn("重启清理群通话参与者失败", zap.Error(err))
} else if len(calls) > 0 {
logger.Info("重启清理群通话参与者", zap.Int("calls", len(calls)))
}
phoneService := phoneapp.NewService(phoneapp.Config{
RingTimeout: cfg.CallRingTimeout,
TombstoneTTL: cfg.CallTombstoneTTL,
MaxActivePerUser: cfg.CallMaxActivePerUser,
SignalingRatePerSecond: cfg.CallSignalingRate,
})
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
secretChatStore := postgres.NewSecretChatStore(pool)
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool)
giftsService := stargifts.NewService(starGiftStore, filesService)
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
passkeyChallengeStore := memory.NewPasskeyChallengeStore()
passkeyService := passkeyapp.NewService(passkeyStore, passkeyChallengeStore, cfg.PasskeyRPID, cfg.DC,
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))
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
)
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
var _ rpc.BatchViewerUsersResolver = usersService
channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithSendPermissionChecker(adminService),
)
businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, logger)
messagesService := messageapp.NewService(messageStore, dialogStore,
messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore), auth.WithBotLogin(botStore), auth.WithPremiumGrant(cfg.PremiumGrantMonths))
accountService := account.NewService(passwordStore,
account.WithReactionSettings(passwordStore),
account.WithAccountSettings(passwordStore),
account.WithNotifySettings(passwordStore),
account.WithStickerCollections(passwordStore),
account.WithSavedMusic(passwordStore),
account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
router := rpc.New(rpc.Config{
DC: cfg.DC,
IP: cfg.AdvertiseIP,
Port: port,
OutboundPushTimeout: cfg.OutboundPushTimeout,
SendRateLimit: cfg.SendRateLimit,
SendRateWindow: cfg.SendRateWindow,
CatchupRateLimit: cfg.CatchupRateLimit,
CatchupRateWindow: cfg.CatchupRateWindow,
ChannelNudgeMaxTargets: cfg.ChannelNudgeMaxTargets,
CallSignalingMaxBytes: cfg.CallSignalingMaxBytes,
CallForceRelay: cfg.CallForceRelay,
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
// PFS temp→perm 解析缓存 5s削减每帧 ResolveAuthKey 的 PG 查询。显式撤销会清缓存并
// 断开连接re-bind 即时失效onAuthBindTempAuthKey
TempKeyResolveCacheTTL: 5 * time.Second,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{
Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore)),
Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)),
Auth: authService,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore),
Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore), users.WithPrivacyEvaluator(privacyService)),
Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)),
Contacts: contacts.NewService(contactStore, userStore).Configure(contacts.WithPhotoProvider(mediaStore), contacts.WithPrivacyEvaluator(privacyService)),
Dialogs: dialogs.NewService(dialogStore, channelStore).Configure(dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(mediaStore), dialogs.WithPrivacyEvaluator(privacyService)),
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(mediaStore), messageapp.WithPrivacyEvaluator(privacyService)),
Channels: channelapp.NewService(channelStore),
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)),
Users: usersService,
Updates: updatesService,
Contacts: contactsService,
Dialogs: dialogsService,
Messages: messagesService,
Channels: channelsService,
Files: filesService,
Bots: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
Phone: phoneService,
SecretChats: secretChatService,
Stars: starsService,
Gifts: giftsService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyStore,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
BaseUsers: userCache,
BotProfiles: botsService,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
Users: usersService,
UserNotifier: router,
Channels: channelsService,
ChannelNotifier: router,
Messages: messagesService,
})
// token revoke 后踢已登录 bot session 经 router 实现(需 tg.* 边界router 创建后注入。
botsService.SetRouterHooks(router)
go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"),
rpc.WithOutboxWorkers(cfg.OutboxWorkers),
rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).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)
go router.RunChannelFanout(ctx)
go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
go router.RunInlineBotPushSubscriber(ctx)
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err)
}
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
return fmt.Errorf("start admin api: %w", err)
}
srv := mtprotoedge.New(mtprotoedge.Options{
Logger: logger.Named("mtprotoedge"),
@ -211,6 +635,16 @@ func run(logger *zap.Logger) error {
Sessions: sessionStore,
ActiveSessions: activeSessions,
ObfuscatedTCP: true,
WebSocket: cfg.WebSocketEnable,
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
})
logger.Info("telesrv 服务就绪",
zap.String("listen", cfg.ListenAddr),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("pid", os.Getpid()),
zap.String("git_commit", buildMeta.Commit),
zap.Uint("schema_version", migrationStatus.Version),
zap.String("blob_backend", "localfs"),
)
return srv.Serve(ctx, ln)
}

937
cmd/walletminiapp/main.go Normal file
View file

@ -0,0 +1,937 @@
// Command walletminiapp is a runnable Mini App demo for telesrv bot developers.
//
// It intentionally behaves like an external bot program: it serves the Mini App
// over HTTP and configures telesrv through the Bot API gateway. It does not write
// directly to telesrv stores, so the demo remains close to a real integration.
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const (
defaultBalanceCents = int64(125000)
maxMemoLen = 180
maxTransferCents = int64(500000)
)
type config struct {
listen string
publicURL string
botAPI string
token string
menuText string
register bool
initMax time.Duration
}
type appServer struct {
cfg config
botapi *botAPIClient
wallet *walletStore
log *log.Logger
}
func main() {
cfg := config{}
flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address")
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button")
flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL")
flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>")
flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label")
flag.BoolVar(&cfg.register, "register", true, "call Bot API setChatMenuButton on startup when token and public-url are set")
flag.DurationVar(&cfg.initMax, "init-max-age", 24*time.Hour, "maximum accepted tgWebAppData age, 0 disables age checks")
flag.Parse()
logger := log.New(os.Stdout, "walletminiapp ", log.LstdFlags|log.Lmicroseconds)
if cfg.publicURL == "" {
cfg.publicURL = "http://" + cfg.listen + "/"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
app := &appServer{
cfg: cfg,
botapi: newBotAPIClient(cfg.botAPI, cfg.token),
wallet: newWalletStore(),
log: logger,
}
if err := app.configureBotMenu(ctx); err != nil {
logger.Fatalf("configure bot menu: %v", err)
}
srv := &http.Server{
Addr: cfg.listen,
Handler: app.routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
logger.Printf("serving wallet mini app on http://%s/", cfg.listen)
logger.Printf("configured public URL: %s", cfg.publicURL)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatalf("serve: %v", err)
}
}
func (s *appServer) configureBotMenu(ctx context.Context) error {
if !s.cfg.register {
s.log.Printf("bot menu registration disabled")
return nil
}
if strings.TrimSpace(s.cfg.token) == "" {
s.log.Printf("bot token missing; serving UI only, Bot API calls disabled")
return nil
}
if !isHTTPSURL(s.cfg.publicURL) {
s.log.Printf("public-url is not HTTPS; serving UI only, skip setChatMenuButton")
return nil
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.botapi.setChatMenuButton(ctx, s.cfg.menuText, s.cfg.publicURL); err != nil {
return err
}
s.log.Printf("registered menu button %q -> %s", s.cfg.menuText, s.cfg.publicURL)
return nil
}
func (s *appServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
})
mux.HandleFunc("/api/session", s.handleSession)
mux.HandleFunc("/api/transfer", s.handleTransfer)
mux.HandleFunc("/api/share-prepared", s.handleSharePrepared)
mux.HandleFunc("/api/answer-webapp-query", s.handleAnswerWebAppQuery)
mux.HandleFunc("/api/payment-intent", s.handlePaymentIntent)
mux.HandleFunc("/", s.handleIndex)
return withSecurityHeaders(mux)
}
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func (s *appServer) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, walletHTML)
}
func (s *appServer) handleSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
raw := firstNonEmpty(r.Header.Get("X-Telegram-Init-Data"), r.URL.Query().Get("init_data"))
session, err := parseWebAppSession(raw, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
snapshot := s.wallet.snapshot(session.User.ID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"session": session,
"wallet": snapshot,
"bot_api": s.botapi.enabled(),
"demo_url": s.cfg.publicURL,
})
}
func (s *appServer) handleTransfer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
var payload transferRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
tx, snapshot, err := s.wallet.transfer(session.User.ID, payload.To, payload.AmountCents, payload.Memo)
if err != nil {
writeError(w, http.StatusBadRequest, "TRANSFER_INVALID", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "session": session, "tx": tx, "wallet": snapshot})
}
func (s *appServer) handleSharePrepared(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
if !s.botapi.enabled() {
writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required")
return
}
var payload shareRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
result := walletInlineResult("wallet-share-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL())
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
prepared, err := s.botapi.savePreparedInlineMessage(ctx, session.User.ID, result)
if err != nil {
writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "prepared": prepared})
}
func (s *appServer) handleAnswerWebAppQuery(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
if !s.botapi.enabled() {
writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required")
return
}
var payload shareRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
if session.QueryID == "" {
writeError(w, http.StatusBadRequest, "QUERY_ID_INVALID", "current launch has no web_app query_id")
return
}
result := walletInlineResult("wallet-answer-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL())
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
out, err := s.botapi.answerWebAppQuery(ctx, session.QueryID, result)
if err != nil {
writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": out})
}
func (s *appServer) handlePaymentIntent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
writeError(w, http.StatusNotImplemented, "PAYMENTS_BLOCKED", "durable invoice/payment state is not implemented in telesrv yet")
}
func (s *appServer) publicResultURL() string {
if isHTTPSURL(s.cfg.publicURL) {
return s.cfg.publicURL
}
return ""
}
type transferRequest struct {
InitData string `json:"init_data"`
To string `json:"to"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
}
type shareRequest struct {
InitData string `json:"init_data"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
}
type webAppSession struct {
Demo bool `json:"demo"`
Verified bool `json:"verified"`
QueryID string `json:"query_id,omitempty"`
StartParam string `json:"start_param,omitempty"`
AuthDate int64 `json:"auth_date,omitempty"`
User webAppUser `json:"user"`
}
type webAppUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
}
func parseWebAppSession(raw, botToken string, maxAge time.Duration) (webAppSession, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return webAppSession{
Demo: true,
User: webAppUser{ID: 0, FirstName: "Browser", Username: "preview"},
}, nil
}
values, err := url.ParseQuery(raw)
if err != nil {
return webAppSession{}, err
}
if strings.TrimSpace(botToken) != "" {
hash := values.Get("hash")
if hash == "" || !validWebAppHash(values, botToken) {
return webAppSession{}, errors.New("hash mismatch")
}
}
var authDate int64
if rawDate := values.Get("auth_date"); rawDate != "" {
authDate, err = strconv.ParseInt(rawDate, 10, 64)
if err != nil || authDate <= 0 {
return webAppSession{}, errors.New("auth_date invalid")
}
if maxAge > 0 && time.Since(time.Unix(authDate, 0)) > maxAge {
return webAppSession{}, errors.New("auth_date expired")
}
}
var user webAppUser
if rawUser := values.Get("user"); rawUser != "" {
if err := json.Unmarshal([]byte(rawUser), &user); err != nil {
return webAppSession{}, errors.New("user invalid")
}
}
if user.ID <= 0 {
return webAppSession{}, errors.New("user missing")
}
return webAppSession{
Verified: strings.TrimSpace(botToken) != "",
QueryID: values.Get("query_id"),
StartParam: values.Get("start_param"),
AuthDate: authDate,
User: user,
}, nil
}
func validWebAppHash(values url.Values, botToken string) bool {
got := values.Get("hash")
if got == "" {
return false
}
want := webAppInitDataHash(values, botToken)
return hmac.Equal([]byte(got), []byte(want))
}
func webAppInitDataHash(values url.Values, botToken string) string {
check := webAppDataCheckString(values)
secretMAC := hmac.New(sha256.New, []byte("WebAppData"))
_, _ = secretMAC.Write([]byte(botToken))
secret := secretMAC.Sum(nil)
dataMAC := hmac.New(sha256.New, secret)
_, _ = dataMAC.Write([]byte(check))
return hex.EncodeToString(dataMAC.Sum(nil))
}
func webAppDataCheckString(values url.Values) string {
keys := make([]string, 0, len(values))
for key := range values {
if key != "hash" {
keys = append(keys, key)
}
}
sort.Strings(keys)
var b strings.Builder
for i, key := range keys {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(key)
b.WriteByte('=')
b.WriteString(values.Get(key))
}
return b.String()
}
type walletStore struct {
mu sync.Mutex
balances map[int64]int64
txs map[int64][]walletTx
nextID int64
}
type walletSnapshot struct {
BalanceCents int64 `json:"balance_cents"`
Currency string `json:"currency"`
Recent []walletTx `json:"recent"`
}
type walletTx struct {
ID int64 `json:"id"`
To string `json:"to"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
CreatedAt int64 `json:"created_at"`
}
func newWalletStore() *walletStore {
return &walletStore{
balances: map[int64]int64{},
txs: map[int64][]walletTx{},
}
}
func (s *walletStore) snapshot(userID int64) walletSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
s.ensure(userID)
return s.snapshotLocked(userID)
}
func (s *walletStore) transfer(userID int64, to string, amount int64, memo string) (walletTx, walletSnapshot, error) {
to = strings.TrimSpace(to)
memo = strings.TrimSpace(memo)
if to == "" {
return walletTx{}, walletSnapshot{}, errors.New("recipient is required")
}
if amount <= 0 || amount > maxTransferCents {
return walletTx{}, walletSnapshot{}, errors.New("amount is out of demo bounds")
}
if len(memo) > maxMemoLen {
return walletTx{}, walletSnapshot{}, errors.New("memo is too long")
}
s.mu.Lock()
defer s.mu.Unlock()
s.ensure(userID)
if s.balances[userID] < amount {
return walletTx{}, walletSnapshot{}, errors.New("insufficient balance")
}
s.nextID++
tx := walletTx{ID: s.nextID, To: to, AmountCents: amount, Memo: memo, CreatedAt: time.Now().Unix()}
s.balances[userID] -= amount
s.txs[userID] = append([]walletTx{tx}, s.txs[userID]...)
if len(s.txs[userID]) > 8 {
s.txs[userID] = s.txs[userID][:8]
}
return tx, s.snapshotLocked(userID), nil
}
func (s *walletStore) ensure(userID int64) {
if _, ok := s.balances[userID]; !ok {
s.balances[userID] = defaultBalanceCents
}
}
func (s *walletStore) snapshotLocked(userID int64) walletSnapshot {
recent := append([]walletTx(nil), s.txs[userID]...)
return walletSnapshot{BalanceCents: s.balances[userID], Currency: "TSC", Recent: recent}
}
type botAPIClient struct {
base string
token string
http *http.Client
}
func newBotAPIClient(base, token string) *botAPIClient {
return &botAPIClient{
base: strings.TrimRight(base, "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *botAPIClient) enabled() bool {
return c != nil && c.base != "" && c.token != ""
}
func (c *botAPIClient) setChatMenuButton(ctx context.Context, text, webURL string) error {
payload := map[string]any{
"menu_button": map[string]any{
"type": "web_app",
"text": text,
"web_app": map[string]any{
"url": webURL,
},
},
}
var out botAPIResponse[bool]
return c.post(ctx, "setChatMenuButton", payload, &out)
}
func (c *botAPIClient) answerWebAppQuery(ctx context.Context, queryID string, result map[string]any) (map[string]any, error) {
payload := map[string]any{"web_app_query_id": queryID, "result": result}
var out botAPIResponse[map[string]any]
if err := c.post(ctx, "answerWebAppQuery", payload, &out); err != nil {
return nil, err
}
return out.Result, nil
}
func (c *botAPIClient) savePreparedInlineMessage(ctx context.Context, userID int64, result map[string]any) (map[string]any, error) {
payload := map[string]any{
"user_id": userID,
"result": result,
"allow_user_chats": true,
"allow_group_chats": true,
"allow_channel_chats": true,
}
var out botAPIResponse[map[string]any]
if err := c.post(ctx, "savePreparedInlineMessage", payload, &out); err != nil {
return nil, err
}
return out.Result, nil
}
func (c *botAPIClient) post(ctx context.Context, method string, payload any, out any) error {
if !c.enabled() {
return errors.New("bot api disabled")
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/bot"+url.PathEscape(c.token)+"/"+method, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
limited := io.LimitReader(resp.Body, 1<<20)
if err := json.NewDecoder(limited).Decode(out); err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s failed with HTTP %d", method, resp.StatusCode)
}
return nil
}
type botAPIResponse[T any] struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result T `json:"result"`
}
func walletInlineResult(id, title, message, link string) map[string]any {
result := map[string]any{
"type": "article",
"id": id,
"title": title,
"description": "Telesrv wallet demo receipt",
"input_message_content": map[string]any{
"message_text": message,
"disable_web_page_preview": true,
},
}
if link != "" {
result["url"] = link
result["reply_markup"] = map[string]any{
"inline_keyboard": [][]map[string]any{{
{"text": "Open Wallet", "url": link},
}},
}
}
return result
}
func receiptText(session webAppSession, payload shareRequest) string {
name := session.User.FirstName
if name == "" {
name = "Wallet user"
}
memo := strings.TrimSpace(payload.Memo)
if memo == "" {
memo = "wallet demo transfer"
}
return fmt.Sprintf("%s shared a wallet receipt: %s TSC - %s", name, formatCents(payload.AmountCents), memo)
}
func decodeJSON(r *http.Request, out any) error {
defer r.Body.Close()
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(out)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
}
func isHTTPSURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func envOr(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func formatCents(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}
const walletHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Telesrv Wallet</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root {
color-scheme: light dark;
--bg: #f5f7f9;
--surface: #ffffff;
--surface-2: #eef3f7;
--text: #13202b;
--muted: #637381;
--line: #d8e0e7;
--accent: #0f8b8d;
--accent-2: #22356f;
--danger: #bf3f3f;
--ok: #237a57;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101418;
--surface: #171e25;
--surface-2: #202a33;
--text: #edf2f7;
--muted: #9aabba;
--line: #2d3944;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
main {
width: min(760px, 100%);
margin: 0 auto;
padding: 18px;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding: 14px 0 18px;
}
h1 { margin: 0; font-size: 24px; font-weight: 760; }
.user { color: var(--muted); margin-top: 4px; }
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
background: var(--surface);
white-space: nowrap;
}
.balance {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
margin-bottom: 14px;
}
.balance label, .field label { color: var(--muted); display: block; margin-bottom: 6px; }
.amount { font-size: 40px; font-weight: 780; line-height: 1.05; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
h2 { margin: 0 0 14px; font-size: 16px; }
.field { margin-bottom: 12px; }
input, textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
border-radius: 7px;
padding: 10px 11px;
font: inherit;
}
textarea { min-height: 76px; resize: vertical; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
button {
border: 0;
border-radius: 7px;
background: var(--accent);
color: white;
font: inherit;
font-weight: 680;
min-height: 40px;
padding: 10px 13px;
cursor: pointer;
}
button.secondary { background: var(--accent-2); }
button.neutral {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--line);
}
button.danger { background: var(--danger); }
button:disabled { opacity: .55; cursor: default; }
.activity { list-style: none; padding: 0; margin: 0; display: grid; gap: 9px; }
.activity li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.activity li:first-child { border-top: 0; }
.muted { color: var(--muted); }
.status { margin-top: 12px; min-height: 22px; color: var(--muted); }
.status.ok { color: var(--ok); }
.status.err { color: var(--danger); }
@media (max-width: 680px) {
main { padding: 14px; }
header { align-items: stretch; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
.amount { font-size: 34px; }
.pill { width: fit-content; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Telesrv Wallet</h1>
<div class="user" id="userLine">Loading session...</div>
</div>
<div class="pill" id="sessionPill">Mini App</div>
</header>
<div class="balance">
<label>Available balance</label>
<div class="amount" id="balance">0.00 TSC</div>
</div>
<div class="grid">
<section>
<h2>Transfer</h2>
<div class="field">
<label for="to">Recipient</label>
<input id="to" autocomplete="off" value="@alice" maxlength="64">
</div>
<div class="field">
<label for="amount">Amount</label>
<input id="amount" type="number" min="0.01" step="0.01" value="12.50">
</div>
<div class="field">
<label for="memo">Memo</label>
<textarea id="memo" maxlength="180">Coffee settlement</textarea>
</div>
<div class="actions">
<button id="sendBtn">Send</button>
<button class="secondary" id="shareBtn">Share</button>
</div>
<div class="status" id="formStatus"></div>
</section>
<section>
<h2>Recent activity</h2>
<ul class="activity" id="activity"></ul>
<div class="actions" style="margin-top:14px">
<button class="neutral" id="receiptBtn">Send receipt</button>
<button class="danger" id="payBtn">Pay invoice</button>
</div>
<div class="status" id="sideStatus"></div>
</section>
</div>
</main>
<script>
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
if (tg) {
tg.ready();
tg.expand();
}
const initData = tg ? tg.initData : "";
let currentSession = null;
const apiURL = (path) => {
const base = location.pathname.endsWith("/") ? location.href : location.href.replace(/[^/]*$/, "");
return new URL(path.replace(/^\//, ""), base);
};
const money = (cents) => (Number(cents || 0) / 100).toFixed(2) + " TSC";
const status = (id, text, kind) => {
const el = document.getElementById(id);
el.textContent = text || "";
el.className = "status " + (kind || "");
};
async function request(path, options) {
const res = await fetch(apiURL(path), {
...options,
headers: { "Content-Type": "application/json", ...(options && options.headers || {}) }
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) throw new Error(body.message || body.error || res.statusText);
return body;
}
function amountCents() {
return Math.round(Number(document.getElementById("amount").value || "0") * 100);
}
function currentPayload() {
return {
init_data: initData,
to: document.getElementById("to").value,
amount_cents: amountCents(),
memo: document.getElementById("memo").value
};
}
function render(data) {
const session = data.session || currentSession || {};
if (data.session) currentSession = data.session;
const wallet = data.wallet || {};
const user = session.user || {};
document.getElementById("userLine").textContent = user.username ? "@" + user.username : (user.first_name || "Browser preview");
document.getElementById("sessionPill").textContent = session.demo ? "Preview" : (session.verified ? "Verified" : "Unsigned");
document.getElementById("balance").textContent = money(wallet.balance_cents);
const activity = document.getElementById("activity");
activity.innerHTML = "";
const recent = wallet.recent || [];
if (!recent.length) {
const li = document.createElement("li");
li.innerHTML = '<span class="muted">No transfers yet</span><span></span>';
activity.appendChild(li);
return;
}
for (const tx of recent) {
const li = document.createElement("li");
li.innerHTML = '<span><strong>' + escapeHTML(tx.to) + '</strong><br><span class="muted">' + escapeHTML(tx.memo || "") + '</span></span><span>-' + money(tx.amount_cents) + '</span>';
activity.appendChild(li);
}
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
async function load() {
const body = await request("api/session", { headers: { "X-Telegram-Init-Data": initData } });
render(body);
}
document.getElementById("sendBtn").addEventListener("click", async () => {
status("formStatus", "Sending...", "");
try {
const body = await request("api/transfer", { method: "POST", body: JSON.stringify(currentPayload()) });
render(body);
status("formStatus", "Transfer recorded.", "ok");
if (tg && tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
} catch (err) {
status("formStatus", err.message, "err");
}
});
document.getElementById("shareBtn").addEventListener("click", async () => {
status("formStatus", "Preparing share...", "");
try {
const body = await request("api/share-prepared", { method: "POST", body: JSON.stringify(currentPayload()) });
const prepared = body.prepared || {};
if (tg && typeof tg.sendPreparedMessage === "function" && prepared.id) {
tg.sendPreparedMessage(prepared.id);
status("formStatus", "Share sheet opened.", "ok");
} else {
status("formStatus", "Prepared id: " + (prepared.id || "n/a"), "ok");
}
} catch (err) {
status("formStatus", err.message, "err");
}
});
document.getElementById("receiptBtn").addEventListener("click", async () => {
status("sideStatus", "Sending receipt...", "");
try {
await request("api/answer-webapp-query", { method: "POST", body: JSON.stringify(currentPayload()) });
status("sideStatus", "Receipt sent to the source chat.", "ok");
} catch (err) {
status("sideStatus", err.message, "err");
}
});
document.getElementById("payBtn").addEventListener("click", async () => {
status("sideStatus", "Opening invoice...", "");
try {
await request("api/payment-intent", { method: "POST", body: JSON.stringify(currentPayload()) });
} catch (err) {
status("sideStatus", err.message, "err");
}
});
load().catch(err => status("formStatus", err.message, "err"));
</script>
</body>
</html>
`

View file

@ -0,0 +1,51 @@
package main
import (
"net/url"
"strconv"
"testing"
"time"
)
func TestParseWebAppSessionValidatesHash(t *testing.T) {
token := "1900000001:test-secret"
values := url.Values{}
values.Set("query_id", "web-query-1")
values.Set("auth_date", strconv.FormatInt(time.Now().Unix(), 10))
values.Set("start_param", "wallet")
values.Set("user", `{"id":1780243210,"first_name":"Alice","username":"alice"}`)
values.Set("hash", webAppInitDataHash(values, token))
session, err := parseWebAppSession(values.Encode(), token, time.Hour)
if err != nil {
t.Fatalf("parseWebAppSession: %v", err)
}
if !session.Verified || session.QueryID != "web-query-1" || session.User.ID != 1780243210 || session.User.Username != "alice" {
t.Fatalf("session = %+v", session)
}
values.Set("user", `{"id":1780243211,"first_name":"Bob"}`)
if _, err := parseWebAppSession(values.Encode(), token, time.Hour); err == nil {
t.Fatal("tampered init data accepted")
}
}
func TestWalletTransferBoundsAndSnapshot(t *testing.T) {
store := newWalletStore()
tx, snapshot, err := store.transfer(1001, "@bob", 1250, "coffee")
if err != nil {
t.Fatalf("transfer: %v", err)
}
if tx.ID == 0 || tx.To != "@bob" || tx.AmountCents != 1250 {
t.Fatalf("tx = %+v", tx)
}
if snapshot.BalanceCents != defaultBalanceCents-1250 || len(snapshot.Recent) != 1 {
t.Fatalf("snapshot = %+v", snapshot)
}
if _, _, err := store.transfer(1001, "", 1250, "coffee"); err == nil {
t.Fatal("empty recipient accepted")
}
if _, _, err := store.transfer(1001, "@bob", maxTransferCents+1, "coffee"); err == nil {
t.Fatal("oversized transfer accepted")
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
"ArchivedChats" = "Archived Chats";
"MenuArchivedChats" = "Archived Chats";
"NewMessageTitle" = "New Message";
"NewChannel" = "New Channel";
"NewGroup" = "New Group";
"MessageUnsupported" = "Unsupported message";
"Weekday.ShortSunday" = "Sun";
"Weekday.ShortMonday" = "Mon";
"Weekday.ShortTuesday" = "Tue";
"Weekday.ShortWednesday" = "Wed";
"Weekday.ShortThursday" = "Thu";
"Weekday.ShortFriday" = "Fri";
"Weekday.ShortSaturday" = "Sat";
"Weekday.Sunday" = "Sunday";
"Weekday.Monday" = "Monday";
"Weekday.Tuesday" = "Tuesday";
"Weekday.Wednesday" = "Wednesday";
"Weekday.Thursday" = "Thursday";
"Weekday.Friday" = "Friday";
"Weekday.Saturday" = "Saturday";
"Weekday.Today" = "Today";
"Weekday.Yesterday" = "Yesterday";

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,20 @@ services:
postgres:
image: postgres:17-alpine
container_name: telesrv-postgres
# 全库已去分区普通表max_locks_per_transaction 默认 64 已够512 仅留余量,对内存近乎零开销。
# pg_stat_statements聚合各 SQL 的累计/平均耗时与调用次数,用于定位 postgres 容器 CPU 热点
# telesrv 是宿主进程docker stats 里的 CPU 尖峰是本容器)。需 CREATE EXTENSION 后才有视图:
# docker exec -i telesrv-postgres psql -U telesrv -d telesrv -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
command:
- "postgres"
- "-c"
- "max_locks_per_transaction=512"
- "-c"
- "shared_preload_libraries=pg_stat_statements"
- "-c"
- "pg_stat_statements.track=all"
- "-c"
- "track_io_timing=on"
environment:
POSTGRES_DB: telesrv
POSTGRES_USER: telesrv

View file

@ -1,4 +1,6 @@
-- 0001_init 回滚:按外键依赖逆序删表。
DROP TABLE IF EXISTS authorizations;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS auth_keys;
-- 0001_init 回滚no-op本文件是全新项目由 0001-0140 迁移链压缩而来的初始 schema。
--
-- 全新项目明确不保留回退路径(沿用 0011 旧迁移的同一约定)。重置数据库请重建:
-- `docker compose -f deploy/docker-compose.yml down -v` 销毁卷后重新迁移,而非 golang-migrate down。
-- golang-migrate 执行本文件即把版本回退到 0不删除任何对象或数据。
SELECT 1;

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,23 @@
-- per-(user, channel, topic) 独立已读水位。
-- 背景forum 话题已读此前被频道级单一水位 channel_members.read_inbox_max_id 承载,
-- 读一个 topic 会污染其它 topic 的未读现算reply_to_top_id 过滤 + id > 频道级水位)。
-- channel_forum_topics 那几个 read_*_max_id 列无 user 维度PK=channel_id,topic_id
-- 无法承载 per-viewer 已读故新建本表topic_id=1 表示 General。
CREATE TABLE public.channel_topic_read (
channel_id bigint NOT NULL,
user_id bigint NOT NULL,
topic_id integer NOT NULL,
read_inbox_max_id integer DEFAULT 0 NOT NULL,
read_outbox_max_id integer DEFAULT 0 NOT NULL,
read_inbox_date integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT channel_topic_read_pkey PRIMARY KEY (channel_id, user_id, topic_id),
CONSTRAINT channel_topic_read_topic_check CHECK (topic_id > 0),
CONSTRAINT channel_topic_read_channel_fkey FOREIGN KEY (channel_id)
REFERENCES public.channels (id) ON DELETE CASCADE
);
-- outbox 回执反查:某 topic 内某 viewer 自己消息被读到哪 = 该 topic 其他成员 read_inbox_max_id 的最大值。
-- (channel_id, topic_id, read_inbox_max_id DESC) 让 MAX 走 index-only避免每帧全表聚合。
CREATE INDEX channel_topic_read_outbox_idx
ON public.channel_topic_read (channel_id, topic_id, read_inbox_max_id DESC);

View file

@ -1,5 +0,0 @@
DROP TABLE IF EXISTS lang_pack_strings;
DROP TABLE IF EXISTS lang_packs;
DROP TABLE IF EXISTS dialogs;
DROP TABLE IF EXISTS contacts;
DROP TABLE IF EXISTS update_states;

View file

@ -1,73 +0,0 @@
-- 0002_phase1_business: first-stage business persistence for startup RPCs.
--
-- 表结构按 telesrv domain/store 边界建模,不引入旧工程依赖。
CREATE TABLE IF NOT EXISTS update_states (
auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE,
pts INT NOT NULL DEFAULT 0,
qts INT NOT NULL DEFAULT 0,
date INT NOT NULL DEFAULT 0,
seq INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS contacts (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mutual BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, contact_user_id)
);
CREATE INDEX IF NOT EXISTS contacts_contact_user_id_idx ON contacts (contact_user_id);
CREATE TABLE IF NOT EXISTS dialogs (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_type VARCHAR(16) NOT NULL,
peer_id BIGINT NOT NULL,
top_message_id INT NOT NULL DEFAULT 0,
read_inbox_max_id INT NOT NULL DEFAULT 0,
read_outbox_max_id INT NOT NULL DEFAULT 0,
unread_count INT NOT NULL DEFAULT 0,
unread_mentions_count INT NOT NULL DEFAULT 0,
unread_reactions_count INT NOT NULL DEFAULT 0,
pinned BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, peer_type, peer_id),
CONSTRAINT dialogs_peer_type_check CHECK (peer_type IN ('user'))
);
CREATE INDEX IF NOT EXISTS dialogs_user_updated_idx ON dialogs (user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS dialogs_user_pinned_idx ON dialogs (user_id, pinned) WHERE pinned;
CREATE TABLE IF NOT EXISTS lang_packs (
lang_pack VARCHAR(32) NOT NULL,
lang_code VARCHAR(64) NOT NULL,
version INT NOT NULL,
strings_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (lang_pack, lang_code)
);
CREATE TABLE IF NOT EXISTS lang_pack_strings (
lang_pack VARCHAR(32) NOT NULL,
lang_code VARCHAR(64) NOT NULL,
key VARCHAR(128) NOT NULL,
version INT NOT NULL,
pluralized BOOLEAN NOT NULL DEFAULT false,
value TEXT NOT NULL DEFAULT '',
zero_value TEXT NOT NULL DEFAULT '',
one_value TEXT NOT NULL DEFAULT '',
two_value TEXT NOT NULL DEFAULT '',
few_value TEXT NOT NULL DEFAULT '',
many_value TEXT NOT NULL DEFAULT '',
other_value TEXT NOT NULL DEFAULT '',
deleted BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (lang_pack, lang_code, key),
FOREIGN KEY (lang_pack, lang_code) REFERENCES lang_packs(lang_pack, lang_code) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS lang_pack_strings_pack_version_idx
ON lang_pack_strings (lang_pack, lang_code, version);

View file

@ -1,5 +0,0 @@
DROP TABLE IF EXISTS temp_auth_key_bindings;
DROP TABLE IF EXISTS country_codes;
DROP TABLE IF EXISTS countries;
DROP TABLE IF EXISTS app_configs;
DROP TABLE IF EXISTS account_passwords;

View file

@ -1,64 +0,0 @@
-- 0003_startup_config_security: data-backed startup config, countries and account security.
CREATE TABLE IF NOT EXISTS account_passwords (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
has_recovery BOOLEAN NOT NULL DEFAULT false,
has_secure_values BOOLEAN NOT NULL DEFAULT false,
has_password BOOLEAN NOT NULL DEFAULT false,
hint VARCHAR(256) NOT NULL DEFAULT '',
email_unconfirmed_pattern VARCHAR(256) NOT NULL DEFAULT '',
login_email_pattern VARCHAR(256) NOT NULL DEFAULT '',
secure_random BYTEA NOT NULL DEFAULT decode('74656c657372762d746465736b746f702d6465762d7365637572652d72616e64', 'hex'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS app_configs (
client VARCHAR(64) PRIMARY KEY,
hash INT NOT NULL,
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS countries (
iso2 VARCHAR(2) PRIMARY KEY,
default_name VARCHAR(128) NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '',
hidden BOOLEAN NOT NULL DEFAULT false,
order_index INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS country_codes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
iso2 VARCHAR(2) NOT NULL REFERENCES countries(iso2) ON DELETE CASCADE,
country_code VARCHAR(16) NOT NULL,
prefixes TEXT[] NOT NULL DEFAULT '{}',
patterns TEXT[] NOT NULL DEFAULT '{}',
order_index INT NOT NULL DEFAULT 0,
UNIQUE (iso2, country_code)
);
CREATE TABLE IF NOT EXISTS temp_auth_key_bindings (
temp_auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE,
perm_auth_key_id BIGINT NOT NULL,
nonce BIGINT NOT NULL,
expires_at INT NOT NULL,
encrypted_message BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO app_configs (client, hash, config_json)
VALUES ('tdesktop', 1, '{}'::jsonb)
ON CONFLICT (client) DO NOTHING;
INSERT INTO countries (iso2, default_name, name, hidden, order_index)
VALUES
('US', 'United States', '', false, 10),
('CN', 'China', '', false, 20)
ON CONFLICT (iso2) DO NOTHING;
INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index)
VALUES
('US', '1', ARRAY['1'], '{}'::text[], 10),
('CN', '86', ARRAY['86'], '{}'::text[], 20)
ON CONFLICT (iso2, country_code) DO NOTHING;

View file

@ -0,0 +1,14 @@
-- 回滚:恢复不含 forum 话题已读两种事件类型的原始白名单(与 0001 一致)。
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop'
]::text[])
);

View file

@ -0,0 +1,20 @@
-- 修复forum 话题已读0002/a180fbc新增了 user_update_events 事件类型
-- read_channel_discussion_inbox / read_channel_discussion_outboxupdateReadChannelDiscussionInbox/
-- Outbox 的 durable 多设备同步),但 0001 的 user_update_events_type_check 白名单未包含它们。
-- 后果:真 PostgreSQL 上 messages.readDiscussion 首次标已读时 append durable 事件被 CHECK
-- 约束拒绝SQLSTATE 23514→ RPC 返回 500且话题已读无法进入 durable / getDifference
-- 用户其它设备收不到 per-topic 已读。memory store 无 CHECK 约束故单测未暴露,真双机才发现。
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);

View file

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

View file

@ -0,0 +1,23 @@
-- 账号级单例设置(每用户一行):全局隐私开关、账号自毁期限、敏感内容开关、
-- 联系人注册通知静音。此前这些 account.* RPC 为硬编码回显 stub不持久化
-- account.get/setGlobalPrivacySettings、get/setAccountTTL、get/setContentSettings、
-- get/setContactSignUpNotification。本表落地真实持久化。
CREATE TABLE public.account_settings (
user_id bigint NOT NULL,
archive_and_mute_new_noncontact_peers boolean DEFAULT false NOT NULL,
keep_archived_unmuted boolean DEFAULT false NOT NULL,
keep_archived_folders boolean DEFAULT false NOT NULL,
hide_read_marks boolean DEFAULT false NOT NULL,
new_noncontact_peers_require_premium boolean DEFAULT false NOT NULL,
display_gifts_button boolean DEFAULT false NOT NULL,
noncontact_peers_paid_stars bigint DEFAULT 0 NOT NULL,
account_ttl_days integer DEFAULT 365 NOT NULL,
sensitive_content_enabled boolean DEFAULT false NOT NULL,
contact_signup_silent boolean DEFAULT false NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_settings_account_ttl_days_check CHECK ((account_ttl_days > 0)),
CONSTRAINT account_settings_noncontact_peers_paid_stars_check CHECK ((noncontact_peers_paid_stars >= 0))
);
ALTER TABLE ONLY public.account_settings
ADD CONSTRAINT account_settings_pkey PRIMARY KEY (user_id);

View file

@ -1,2 +0,0 @@
ALTER TABLE temp_auth_key_bindings
DROP COLUMN IF EXISTS temp_session_id;

View file

@ -1,4 +0,0 @@
-- 0004_temp_auth_key_binding_session: persist validated bind_auth_key_inner session id.
ALTER TABLE temp_auth_key_bindings
ADD COLUMN IF NOT EXISTS temp_session_id BIGINT NOT NULL DEFAULT 0;

View file

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

View file

@ -0,0 +1,24 @@
-- per-scope 通知设置(每用户多行):具体 peer / forum 话题 / 三类全局默认
-- users/chats/broadcasts。此前 account.get/update/resetNotifySettings 为回显
-- stub不持久化mute 重启即丢、dialog 列表静音状态不正确。本表落地真实持久化。
-- 可空 bool/int 列表示“该项未设置=按所属类别继承默认”(与 TL flag-optional 一致)。
CREATE TABLE public.notify_settings (
owner_user_id bigint NOT NULL,
scope_kind text NOT NULL,
peer_type text DEFAULT ''::text NOT NULL,
peer_id bigint DEFAULT 0 NOT NULL,
topic_id integer DEFAULT 0 NOT NULL,
show_previews boolean,
silent boolean,
mute_until integer,
stories_muted boolean,
stories_hide_sender boolean,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notify_settings_scope_kind_check CHECK ((scope_kind = ANY (ARRAY['peer'::text, 'users'::text, 'chats'::text, 'broadcasts'::text])))
);
ALTER TABLE ONLY public.notify_settings
ADD CONSTRAINT notify_settings_pkey PRIMARY KEY (owner_user_id, scope_kind, peer_type, peer_id, topic_id);
-- dialog 列表批量读:按 owner + 一组 peer 取整-peertopic 0设置。
CREATE INDEX notify_settings_owner_peer_idx ON public.notify_settings USING btree (owner_user_id, peer_type, peer_id) WHERE ((scope_kind = 'peer'::text) AND (topic_id = 0));

View file

@ -1,11 +0,0 @@
DROP INDEX IF EXISTS messages_owner_date_idx;
DROP INDEX IF EXISTS messages_owner_dialog_idx;
DROP TABLE IF EXISTS messages;
DROP INDEX IF EXISTS dialogs_user_top_message_idx;
ALTER TABLE dialogs DROP COLUMN IF EXISTS top_message_date;
DELETE FROM users WHERE id = 777000;
ALTER TABLE users
DROP COLUMN IF EXISTS support,
DROP COLUMN IF EXISTS verified;

View file

@ -1,44 +0,0 @@
-- 0005_system_login_messages: official system account and first message persistence.
--
-- 777000 官方账号与登录消息推送;表结构按 telesrv domain/store 边界建模。
ALTER TABLE users
ADD COLUMN IF NOT EXISTS verified BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS support BOOLEAN NOT NULL DEFAULT false;
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support)
VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', true, true)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
updated_at = now();
ALTER TABLE dialogs
ADD COLUMN IF NOT EXISTS top_message_date INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS dialogs_user_top_message_idx
ON dialogs (user_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC);
CREATE TABLE IF NOT EXISTS messages (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_type VARCHAR(16) NOT NULL,
peer_id BIGINT NOT NULL,
from_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
message_date INT NOT NULL,
outgoing BOOLEAN NOT NULL DEFAULT false,
body TEXT NOT NULL DEFAULT '',
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT messages_peer_type_check CHECK (peer_type IN ('user'))
);
CREATE INDEX IF NOT EXISTS messages_owner_dialog_idx
ON messages (owner_user_id, peer_type, peer_id, id DESC);
CREATE INDEX IF NOT EXISTS messages_owner_date_idx
ON messages (owner_user_id, message_date DESC, id DESC);

View file

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

View file

@ -0,0 +1,18 @@
-- per-user 个人贴纸/GIF 集合:收藏贴纸 / 最近贴纸 / attach 最近贴纸 / 保存的 GIF。
-- 此前 messages.faveSticker/saveRecentSticker/saveGif 未注册(NOT_IMPLEMENTED)、
-- getFaved/getRecent/getSavedGifs 返回空 stub。本表落地真实持久化。
-- used_at 为入列/最近使用时刻,读取按 used_at DESC最新在前document_id 引用
-- documents 表,读路径经 Files.GetDocuments 解析为完整文档。
CREATE TABLE public.user_sticker_collections (
owner_user_id bigint NOT NULL,
kind text NOT NULL,
document_id bigint NOT NULL,
used_at integer DEFAULT 0 NOT NULL,
CONSTRAINT user_sticker_collections_kind_check CHECK ((kind = ANY (ARRAY['faved'::text, 'recent'::text, 'recent_attached'::text, 'gif'::text])))
);
ALTER TABLE ONLY public.user_sticker_collections
ADD CONSTRAINT user_sticker_collections_pkey PRIMARY KEY (owner_user_id, kind, document_id);
-- 读取/截断按 (owner, kind, used_at DESC)。
CREATE INDEX user_sticker_collections_order_idx ON public.user_sticker_collections USING btree (owner_user_id, kind, used_at DESC);

View file

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS update_events_auth_pts_idx;
DROP TABLE IF EXISTS update_events;

View file

@ -1,16 +0,0 @@
-- 0006_update_events: minimal auth-key update queue for getDifference补偿.
CREATE TABLE IF NOT EXISTS update_events (
auth_key_id BIGINT NOT NULL REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE,
pts INT NOT NULL,
pts_count INT NOT NULL DEFAULT 1,
date INT NOT NULL,
event_type VARCHAR(32) NOT NULL,
message_id INT REFERENCES messages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (auth_key_id, pts),
CONSTRAINT update_events_type_check CHECK (event_type IN ('new_message'))
);
CREATE INDEX IF NOT EXISTS update_events_auth_pts_idx
ON update_events (auth_key_id, pts);

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.dialogs_peer_id_idx;

View file

@ -0,0 +1,9 @@
-- dialogs 按 peer_id 的反向索引。
-- dialog_light 失效扇出函数 telesrv_bump_private_dialog_light_for_user 按
-- peer_id 过滤 dialogs"哪些用户与该 user 有私聊会话"),但 dialogs 上所有
-- 既有索引都以 user_id 前导(主键 + dialogs_user_folder_top_message_idx +
-- dialogs_user_pinned_order_idx该按 peer_id 的查询此前只能 Seq Scan。
-- dialogs 有 CHECK (peer_type = 'user'),故单列 (peer_id) 即精确命中、无需
-- peer_type 前缀,与 contacts.contacts_contact_user_id_idx 对称。资料变更 /
-- premium 到期触发扇出时,从全表扫降为索引扫(随表规模增长收益放大)。
CREATE INDEX dialogs_peer_id_idx ON public.dialogs USING btree (peer_id);

View file

@ -1,15 +0,0 @@
ALTER TABLE update_events
DROP CONSTRAINT IF EXISTS update_events_peer_type_check;
ALTER TABLE update_events
DROP CONSTRAINT IF EXISTS update_events_type_check;
ALTER TABLE update_events
ADD CONSTRAINT update_events_type_check
CHECK (event_type IN ('new_message'));
ALTER TABLE update_events
DROP COLUMN IF EXISTS still_unread_count,
DROP COLUMN IF EXISTS max_id,
DROP COLUMN IF EXISTS peer_id,
DROP COLUMN IF EXISTS peer_type;

View file

@ -1,23 +0,0 @@
-- 0007_read_history_events: persist readHistory update events for getDifference.
--
-- updateReadHistoryInbox真正发生已读推进时递增 pts并给其它 session / getDifference 留可补偿事件。
ALTER TABLE update_events
ADD COLUMN IF NOT EXISTS peer_type VARCHAR(16),
ADD COLUMN IF NOT EXISTS peer_id BIGINT,
ADD COLUMN IF NOT EXISTS max_id INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS still_unread_count INT NOT NULL DEFAULT 0;
ALTER TABLE update_events
DROP CONSTRAINT IF EXISTS update_events_type_check;
ALTER TABLE update_events
ADD CONSTRAINT update_events_type_check
CHECK (event_type IN ('new_message', 'read_history_inbox'));
ALTER TABLE update_events
DROP CONSTRAINT IF EXISTS update_events_peer_type_check;
ALTER TABLE update_events
ADD CONSTRAINT update_events_peer_type_check
CHECK (peer_type IS NULL OR peer_type IN ('user'));

View file

@ -0,0 +1,3 @@
ALTER TABLE public.channel_messages DROP COLUMN IF EXISTS grouped_id;
ALTER TABLE public.message_boxes DROP COLUMN IF EXISTS grouped_id;
ALTER TABLE public.private_messages DROP COLUMN IF EXISTS grouped_id;

View file

@ -0,0 +1,6 @@
-- 相册分组 id同一次 messages.sendMultiMedia 的各条消息共享一个非零 grouped_id
-- 客户端据此把它们渲染成一个相册组。此前 sendMultiMedia 不绑定 grouped_id各条独立气泡
-- 镜像 via_bot_id 的三张表:共享私聊主体 + 每 owner 收件箱 + 频道消息。
ALTER TABLE public.private_messages ADD COLUMN grouped_id bigint DEFAULT 0 NOT NULL;
ALTER TABLE public.message_boxes ADD COLUMN grouped_id bigint DEFAULT 0 NOT NULL;
ALTER TABLE public.channel_messages ADD COLUMN grouped_id bigint DEFAULT 0 NOT NULL;

View file

@ -1,8 +0,0 @@
-- Revert generated ordinary user ids to the original first-phase base when possible.
-- Existing users keep their ids; the next generated id is never moved below MAX(id)+1.
SELECT setval(
pg_get_serial_sequence('users', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 0) FROM users), 999999999),
true
);

View file

@ -1,10 +0,0 @@
-- 0008_user_id_sequence_base: move ordinary user ids to the agreed timestamp range.
--
-- Base: 2026-06-01 00:00:00 Asia/Shanghai => Unix seconds 1780243200.
-- Existing users keep their ids; the next generated id is at least this base.
SELECT setval(
pg_get_serial_sequence('users', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 0) FROM users), 1780243199),
true
);

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