chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
20
.env.example
Normal file
20
.env.example
Normal 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
5
.gitignore
vendored
|
|
@ -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
201
LICENSE
Normal 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
159
README.md
|
|
@ -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.
|
||||
|
|
|
|||
128
README.zh-CN.md
128
README.zh-CN.md
|
|
@ -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
400
cmd/appearancefetch/main.go
Normal 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
307
cmd/botcheck/main.go
Normal 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
|
||||
//
|
||||
// 连接生产 telesrv(obfuscated 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 obfuscation(obfuscated2)连接,
|
||||
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListener(obfuscated2.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)
|
||||
}
|
||||
// NoHeader:tag 已由 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 类型 = %T,want *tg.AuthAuthorization", authz)
|
||||
}
|
||||
self, ok := a.User.(*tg.User)
|
||||
if !ok {
|
||||
return fmt.Errorf("self 类型 = %T,want *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
479
cmd/botdemo/main.go
Normal 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 obfuscation(obfuscated2),与生产 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 obfuscation(obfuscated2)连接 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_hash(telesrv 下可留 0)")
|
||||
toID := flag.Int64("to", 0, "目标用户 id(-chat 为 0 时改为私聊该用户)")
|
||||
toHash := flag.Int64("to-hash", 0, "目标用户 access_hash(telesrv 下可留 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=%q(text|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 解析出真实 hash(telesrv 允许 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
190
cmd/catalogfetch/main.go
Normal 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
294
cmd/giftcheck/main.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// Command giftcheck 是 Star 礼物端到端验证工具(开发用,非生产组件)。
|
||||
//
|
||||
// 以用户身份登录本地 telesrv,验证 star gift 全链路对 live 服务端:
|
||||
// 1. payments.getStarGifts —— 打印礼物目录。
|
||||
// 2. 从 dialogs 找一个收礼用户(或 -to 指定)。
|
||||
// 3. payments.getPaymentForm(inputInvoiceStarGift) —— 必须返 paymentFormStarGift(XTR+非空 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, "收礼用户 id(0=自动从 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 返回 %T,want *PaymentsPaymentFormStarGift(TDesktop 单分支 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 返回 %T,want *PaymentsPaymentResult(DrKLO 强转)\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("==== ✅ PASS:star 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
127
cmd/langpackfetch/main.go
Normal 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
304
cmd/starcheck/main.go
Normal 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, "指定频道 id(0=自动从 dialogs 找广播频道)")
|
||||
accessHash := flag.Int64("access-hash", 0, "指定频道 access_hash")
|
||||
msgID := flag.Int("msg-id", 0, "指定消息 id(0=用频道顶部消息)")
|
||||
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
385
cmd/stickerfetch/main.go
Normal 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
118
cmd/telesrv-admin/main.go
Normal 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[:])
|
||||
}
|
||||
766
cmd/telesrv-admin/readstore.go
Normal file
766
cmd/telesrv-admin/readstore.go
Normal 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
711
cmd/telesrv-admin/server.go
Normal 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()
|
||||
}
|
||||
68
cmd/telesrv-admin/session.go
Normal file
68
cmd/telesrv-admin/session.go
Normal 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,
|
||||
})
|
||||
}
|
||||
44
cmd/telesrv-admin/session_test.go
Normal file
44
cmd/telesrv-admin/session_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
1
cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js
vendored
Normal file
8
cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
cmd/telesrv-admin/web/dist/index.html
vendored
Normal file
13
cmd/telesrv-admin/web/dist/index.html
vendored
Normal 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>
|
||||
12
cmd/telesrv-admin/web/index.html
Normal file
12
cmd/telesrv-admin/web/index.html
Normal 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
1017
cmd/telesrv-admin/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
cmd/telesrv-admin/web/package.json
Normal file
23
cmd/telesrv-admin/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
cmd/telesrv-admin/web/src/App.tsx
Normal file
48
cmd/telesrv-admin/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
cmd/telesrv-admin/web/src/api.ts
Normal file
72
cmd/telesrv-admin/web/src/api.ts
Normal 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)
|
||||
})
|
||||
};
|
||||
146
cmd/telesrv-admin/web/src/components/ActionButton.tsx
Normal file
146
cmd/telesrv-admin/web/src/components/ActionButton.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
cmd/telesrv-admin/web/src/components/AppLink.tsx
Normal file
27
cmd/telesrv-admin/web/src/components/AppLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
cmd/telesrv-admin/web/src/components/AuthorizationTable.tsx
Normal file
82
cmd/telesrv-admin/web/src/components/AuthorizationTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
cmd/telesrv-admin/web/src/components/EntityPicker.tsx
Normal file
192
cmd/telesrv-admin/web/src/components/EntityPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
cmd/telesrv-admin/web/src/components/Layout.tsx
Normal file
155
cmd/telesrv-admin/web/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
cmd/telesrv-admin/web/src/components/ui.tsx
Normal file
128
cmd/telesrv-admin/web/src/components/ui.tsx
Normal 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>;
|
||||
}
|
||||
56
cmd/telesrv-admin/web/src/lib/format.ts
Normal file
56
cmd/telesrv-admin/web/src/lib/format.ts
Normal 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;
|
||||
}
|
||||
25
cmd/telesrv-admin/web/src/lib/metrics.ts
Normal file
25
cmd/telesrv-admin/web/src/lib/metrics.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
10
cmd/telesrv-admin/web/src/main.tsx
Normal file
10
cmd/telesrv-admin/web/src/main.tsx
Normal 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>
|
||||
);
|
||||
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal file
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal file
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal file
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal file
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal file
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal file
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal file
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal file
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal file
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal file
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal 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} />;
|
||||
}
|
||||
29
cmd/telesrv-admin/web/src/routing.ts
Normal file
29
cmd/telesrv-admin/web/src/routing.ts
Normal 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 "控制台 / 总览";
|
||||
}
|
||||
5
cmd/telesrv-admin/web/src/styles.css
Normal file
5
cmd/telesrv-admin/web/src/styles.css
Normal 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";
|
||||
285
cmd/telesrv-admin/web/src/styles/01-foundation.css
Normal file
285
cmd/telesrv-admin/web/src/styles/01-foundation.css
Normal 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;
|
||||
}
|
||||
559
cmd/telesrv-admin/web/src/styles/02-pages-and-forms.css
Normal file
559
cmd/telesrv-admin/web/src/styles/02-pages-and-forms.css
Normal 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;
|
||||
}
|
||||
254
cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css
Normal file
254
cmd/telesrv-admin/web/src/styles/03-entities-and-actions.css
Normal 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;
|
||||
}
|
||||
261
cmd/telesrv-admin/web/src/styles/04-modal-and-login.css
Normal file
261
cmd/telesrv-admin/web/src/styles/04-modal-and-login.css
Normal 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);
|
||||
}
|
||||
}
|
||||
78
cmd/telesrv-admin/web/src/styles/05-responsive.css
Normal file
78
cmd/telesrv-admin/web/src/styles/05-responsive.css
Normal 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;
|
||||
}
|
||||
}
|
||||
222
cmd/telesrv-admin/web/src/types.ts
Normal file
222
cmd/telesrv-admin/web/src/types.ts
Normal 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[];
|
||||
};
|
||||
20
cmd/telesrv-admin/web/tsconfig.json
Normal file
20
cmd/telesrv-admin/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
16
cmd/telesrv-admin/web/vite.config.ts
Normal file
16
cmd/telesrv-admin/web/vite.config.ts
Normal 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
54
cmd/telesrv/buildinfo.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 按配置启用外链媒体抓取;禁用时返回 nil(NewService 跳过 nil option)。
|
||||
func externalMediaOption(cfg config.Config) filesapp.Option {
|
||||
if !cfg.ExternalMediaEnable {
|
||||
return nil
|
||||
}
|
||||
return filesapp.WithExternalMedia(cfg.ExternalMediaMaxBytes, cfg.ExternalMediaRatePerMin)
|
||||
}
|
||||
|
||||
// webPagePreviewOption 按配置启用链接预览抓取;禁用时返回 nil(NewService 跳过 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.0(Layer 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,65 +348,303 @@ 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 SFU(M1+)。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/STUN,phoneCall.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 状态全失,把全部活跃通话的参与者批量置 left(version++),
|
||||
// 客户端经 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,
|
||||
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)),
|
||||
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),
|
||||
Files: filesService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Limiter: rateLimiter,
|
||||
Auth: authService,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
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"),
|
||||
DC: cfg.DC,
|
||||
RSAKey: rsaKey,
|
||||
RPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
Sessions: sessionStore,
|
||||
ActiveSessions: activeSessions,
|
||||
ObfuscatedTCP: true,
|
||||
Logger: logger.Named("mtprotoedge"),
|
||||
DC: cfg.DC,
|
||||
RSAKey: rsaKey,
|
||||
RPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
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
937
cmd/walletminiapp/main.go
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
`
|
||||
51
cmd/walletminiapp/main_test.go
Normal file
51
cmd/walletminiapp/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
11655
data/langpack/android/android_en_v59634849.strings
Normal file
11655
data/langpack/android/android_en_v59634849.strings
Normal file
File diff suppressed because it is too large
Load diff
10973
data/langpack/android/android_zh-hans_v59665710.strings
Normal file
10973
data/langpack/android/android_zh-hans_v59665710.strings
Normal file
File diff suppressed because it is too large
Load diff
12428
data/langpack/ios/ios_en_v21268555.strings
Normal file
12428
data/langpack/ios/ios_en_v21268555.strings
Normal file
File diff suppressed because it is too large
Load diff
11721
data/langpack/ios/ios_zh-hans_v21265098.strings
Normal file
11721
data/langpack/ios/ios_zh-hans_v21265098.strings
Normal file
File diff suppressed because it is too large
Load diff
8366
data/langpack/macos/macos_en_v2224214.strings
Normal file
8366
data/langpack/macos/macos_en_v2224214.strings
Normal file
File diff suppressed because it is too large
Load diff
8060
data/langpack/macos/macos_zh-hans_v2221980.strings
Normal file
8060
data/langpack/macos/macos_zh-hans_v2221980.strings
Normal file
File diff suppressed because it is too large
Load diff
24
data/langpack/weba/weba_en_v12000001.strings
Normal file
24
data/langpack/weba/weba_en_v12000001.strings
Normal 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";
|
||||
1391
data/langpack/weba/weba_zh-hans_v263556.strings
Normal file
1391
data/langpack/weba/weba_zh-hans_v263556.strings
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
deploy/migrations/0002_channel_topic_read.down.sql
Normal file
1
deploy/migrations/0002_channel_topic_read.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.channel_topic_read;
|
||||
23
deploy/migrations/0002_channel_topic_read.up.sql
Normal file
23
deploy/migrations/0002_channel_topic_read.up.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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[])
|
||||
);
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- 修复:forum 话题已读(0002/a180fbc)新增了 user_update_events 事件类型
|
||||
-- read_channel_discussion_inbox / read_channel_discussion_outbox(updateReadChannelDiscussionInbox/
|
||||
-- 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[])
|
||||
);
|
||||
1
deploy/migrations/0004_account_settings.down.sql
Normal file
1
deploy/migrations/0004_account_settings.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.account_settings;
|
||||
23
deploy/migrations/0004_account_settings.up.sql
Normal file
23
deploy/migrations/0004_account_settings.up.sql
Normal 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);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE temp_auth_key_bindings
|
||||
DROP COLUMN IF EXISTS temp_session_id;
|
||||
|
|
@ -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;
|
||||
1
deploy/migrations/0005_notify_settings.down.sql
Normal file
1
deploy/migrations/0005_notify_settings.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.notify_settings;
|
||||
24
deploy/migrations/0005_notify_settings.up.sql
Normal file
24
deploy/migrations/0005_notify_settings.up.sql
Normal 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 取整-peer(topic 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));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
1
deploy/migrations/0006_sticker_collections.down.sql
Normal file
1
deploy/migrations/0006_sticker_collections.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.user_sticker_collections;
|
||||
18
deploy/migrations/0006_sticker_collections.up.sql
Normal file
18
deploy/migrations/0006_sticker_collections.up.sql
Normal 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);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP INDEX IF EXISTS update_events_auth_pts_idx;
|
||||
DROP TABLE IF EXISTS update_events;
|
||||
|
|
@ -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);
|
||||
1
deploy/migrations/0007_dialogs_peer_id_index.down.sql
Normal file
1
deploy/migrations/0007_dialogs_peer_id_index.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.dialogs_peer_id_idx;
|
||||
9
deploy/migrations/0007_dialogs_peer_id_index.up.sql
Normal file
9
deploy/migrations/0007_dialogs_peer_id_index.up.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
3
deploy/migrations/0008_message_grouped_id.down.sql
Normal file
3
deploy/migrations/0008_message_grouped_id.down.sql
Normal 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;
|
||||
6
deploy/migrations/0008_message_grouped_id.up.sql
Normal file
6
deploy/migrations/0008_message_grouped_id.up.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue